diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 2891a71..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: build - -on: - pull_request_target: - paths: - - "go.*" - - "**/*.go" - - "Taskfile.yml" - - "Dockerfile.release" - - ".github/workflows/*.yml" - -permissions: - contents: read - -jobs: - # ------------------------------ - - govulncheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: setup - run: task setup - - - name: install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest - - - name: run govulncheck - run: govulncheck ./... - - # ------------------------------ - - semgrep: - runs-on: ubuntu-latest - container: - image: returntocorp/semgrep - steps: - - uses: actions/checkout@v4 - - - uses: actions/checkout@v4 - with: - repository: dgryski/semgrep-go - path: rules - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: semgrep - run: semgrep scan --error --enable-nosem -f ./rules . - - # ------------------------------ - - test: - runs-on: ubuntu-latest - env: - DOCKER_CLI_EXPERIMENTAL: "enabled" - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # - uses: docker/setup-qemu-action@v3 - - # - uses: docker/setup-buildx-action@v3 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: setup-tparse - run: go install github.com/mfridman/tparse@latest - - - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: setup - run: | - task setup - task build - - - name: test - run: ./scripts/test.sh - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - - - name: Ensure scm-engine binary work - run: ./scm-engine -h - - - name: Test scm-engine against test GitLab project - run: ./scm-engine evaluate 1 - env: - SCM_ENGINE_TOKEN: "${{ secrets.GITLAB_INTEGRATION_TEST_API_TOKEN }}" - SCM_ENGINE_CONFIG_FILE: ".scm-engine.example.yml" - GITLAB_PROJECT: "jippi/scm-engine-schema-test" - GITLAB_BASEURL: https://gitlab.com/ - - - name: Show any diff that may be in the project - run: git diff diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a05f85a..53d1ce5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,8 +2,11 @@ name: "CodeQL" on: push: + tags: + - v* branches: - main + pull_request: jobs: # ------------------------------ diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 699b240..d1dca75 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -2,10 +2,10 @@ name: Gitleaks on: push: - branches: - - "main" tags: - - "v*" + - v* + branches: + - main pull_request: permissions: @@ -20,6 +20,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} - uses: gitleaks/gitleaks-action@v2 env: diff --git a/.github/workflows/grype.yml b/.github/workflows/grype.yml index a6a69b1..baef175 100644 --- a/.github/workflows/grype.yml +++ b/.github/workflows/grype.yml @@ -2,10 +2,10 @@ name: Grype on: push: - branches: - - "main" tags: - - "v*" + - v* + branches: + - main pull_request: jobs: @@ -22,6 +22,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: anchore/scan-action@v3 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 772b8e8..1194c46 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,6 +26,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-go@v5 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 535883e..8f84b35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,6 @@ jobs: DOCKER_CLI_EXPERIMENTAL: "enabled" steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - uses: arduino/setup-task@v2 with: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..aaff3c1 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,63 @@ +name: Security + +on: + push: + tags: + - v* + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + # ------------------------------ + + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: setup + run: task setup + + - name: install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: run govulncheck + run: govulncheck ./... + + # ------------------------------ + + semgrep: + runs-on: ubuntu-latest + container: + image: returntocorp/semgrep + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/checkout@v4 + with: + repository: dgryski/semgrep-go + path: rules + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: semgrep + run: semgrep scan --error --enable-nosem -f ./rules . diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9d27088 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,59 @@ +name: Test + +on: + push: + tags: + - v* + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: setup-tparse + run: go install github.com/mfridman/tparse@latest + + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: setup + run: | + task setup + task build + + - name: test + run: ./scripts/test.sh + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Ensure scm-engine binary work + run: ./scm-engine -h + + - name: Test scm-engine against test GitLab project + run: ./scm-engine evaluate 1 + env: + SCM_ENGINE_TOKEN: "${{ secrets.GITLAB_INTEGRATION_TEST_API_TOKEN }}" + SCM_ENGINE_CONFIG_FILE: ".scm-engine.example.yml" + GITLAB_PROJECT: "jippi/scm-engine-schema-test" + GITLAB_BASEURL: https://gitlab.com/ + + - name: Show any diff that may be in the project + run: git diff diff --git a/.golangci.yaml b/.golangci.yaml index db1b92a..7476201 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -179,6 +179,8 @@ linters-settings: - i - id - ok + - r + - w tagalign: # Align and sort can be used together or separately. diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e60cc48..07190d3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -41,7 +41,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X {{.ModulePath}}/cmd.version={{.Version}} -X {{.ModulePath}}/cmd.commit={{.Commit}} -X {{.ModulePath}}/cmd.date={{ .CommitDate }} -X {{.ModulePath}}/cmd.treeState={{ .IsGitDirty }} + - -s -w -X {{.ModulePath}.version={{.Version}} -X {{.ModulePath}}.commit={{.Commit}} -X {{.ModulePath}}.date={{ .CommitDate }} -X {{.ModulePath}}.treeState={{ .IsGitDirty }} universal_binaries: - replace: false diff --git a/Taskfile.yml b/Taskfile.yml index fa36a93..78680c0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -20,12 +20,12 @@ tasks: build: desc: Build the binary + cmds: + - go build -o scm-engine . sources: - ./**/*.go generates: - ./scm-engine - cmds: - - go build -o scm-engine . test: desc: Run tests diff --git a/cmd/cmd_evaluate.go b/cmd/cmd_evaluate.go index 080dc07..a9dc367 100644 --- a/cmd/cmd_evaluate.go +++ b/cmd/cmd_evaluate.go @@ -32,14 +32,14 @@ func Evaluate(cCtx *cli.Context) error { } for _, mr := range res { - if err := ProcessMR(ctx, client, cfg, mr.ID); err != nil { + if err := ProcessMR(ctx, client, cfg, mr.ID, nil); err != nil { return err } } // If the flag is set, use that for evaluation case cCtx.String(FlagMergeRequestID) != "": - return ProcessMR(ctx, client, cfg, cCtx.String(FlagMergeRequestID)) + return ProcessMR(ctx, client, cfg, cCtx.String(FlagMergeRequestID), nil) // If no flag is set, we require arguments case cCtx.Args().Len() == 0: @@ -47,7 +47,7 @@ func Evaluate(cCtx *cli.Context) error { default: for _, mr := range cCtx.Args().Slice() { - if err := ProcessMR(ctx, client, cfg, mr); err != nil { + if err := ProcessMR(ctx, client, cfg, mr, nil); err != nil { return err } } diff --git a/cmd/cmd_server.go b/cmd/cmd_server.go index 19ec162..6ea86c0 100644 --- a/cmd/cmd_server.go +++ b/cmd/cmd_server.go @@ -1,57 +1,184 @@ package cmd import ( + "bytes" + "context" "encoding/json" - "log" + "errors" + "fmt" + "io" "log/slog" + "net" "net/http" + "strconv" + "time" - "github.com/go-playground/webhooks/v6/gitlab" + "github.com/jippi/scm-engine/pkg/config" + "github.com/jippi/scm-engine/pkg/scm/gitlab" + "github.com/jippi/scm-engine/pkg/state" "github.com/urfave/cli/v2" + slogctx "github.com/veqryn/slog-context" ) -func Server(_ *cli.Context) error { //nolint:unparam +type Commit struct { + ID string `json:"id"` +} + +type MergeRequest struct { + IID int `json:"iid"` + LastCommit Commit `json:"last_commit"` +} + +type Project struct { + PathWithNamespace string `json:"path_with_namespace"` +} + +type Payload struct { + EventType string `json:"event_type"` + Project Project `json:"project"` // "project" is sent for all events + ObjectAttributes *MergeRequest `json:"object_attributes,omitempty"` // "object_attributes" is sent on "merge_request" events + MergeRequest *MergeRequest `json:"merge_request,omitempty"` // "merge_request" is sent on "note" activity +} + +func errHandler(ctx context.Context, w http.ResponseWriter, code int, err error) { + switch code { + case http.StatusOK: + slogctx.Info(ctx, "Server response", slog.Int("response_code", code), slog.Any("response_message", err)) + + default: + slogctx.Error(ctx, "Server response", slog.Int("response_code", code), slog.Any("response_message", err)) + } + + w.WriteHeader(code) + w.Write([]byte(err.Error())) + + return +} + +func Server(cCtx *cli.Context) error { //nolint:unparam + slogctx.Info(cCtx.Context, "Starting HTTP server", slog.String("listen", cCtx.String(FlagServerListen))) + mux := http.NewServeMux() - mux.HandleFunc("POST /mr", func(writer http.ResponseWriter, reader *http.Request) { - if reader.Header.Get("Content-Type") != "application/json" { - slog.Warn("not json") + ourSecret := cCtx.String(FlagWebhookSecret) + + // Initialize GitLab client + client, err := gitlab.NewClient(cCtx.String(FlagAPIToken), cCtx.String(FlagSCMBaseURL)) + if err != nil { + return err + } + + mux.HandleFunc("POST /gitlab", func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() - writer.WriteHeader(http.StatusInternalServerError) + slogctx.Info(ctx, "Handling /gitlab request") + + // Check if the webhook secret is set (and if its matching) + if len(ourSecret) > 0 { + theirSecret := r.Header.Get("X-Gitlab-Token") + if ourSecret != theirSecret { + errHandler(ctx, w, http.StatusForbidden, errors.New("Missing or invalid X-Gitlab-Token header")) + + return + } + } + + // Validate content type + if r.Header.Get("Content-Type") != "application/json" { + errHandler(ctx, w, http.StatusNotAcceptable, errors.New("The request is not using Content-Type: application/json")) return } - var evt gitlab.MergeRequestEventPayload - if err := json.NewDecoder(reader.Body).Decode(&evt); err != nil { - writer.WriteHeader(http.StatusInternalServerError) + // Read the POST body of the request + body, err := io.ReadAll(r.Body) + if err != nil { + errHandler(ctx, w, http.StatusBadRequest, err) return } - writer.WriteHeader(http.StatusOK) - }) + // Ensure we have content in the POST body + if len(body) == 0 { + errHandler(ctx, w, http.StatusBadRequest, errors.New("The POST body is empty; expected a JSON payload")) + } + + // Decode request payload + var payload Payload + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&payload); err != nil { + errHandler(ctx, w, http.StatusBadRequest, fmt.Errorf("could not decode POST body into Payload struct: %w", err)) + + return + } + + // Initialize context + ctx = state.ContextWithProjectID(ctx, payload.Project.PathWithNamespace) + + // Grab event specific information + var ( + id string + ref string + ) + + switch payload.EventType { + case "merge_request": + id = strconv.Itoa(payload.ObjectAttributes.IID) + ref = payload.ObjectAttributes.LastCommit.ID + + case "note": + id = strconv.Itoa(payload.MergeRequest.IID) + ref = payload.MergeRequest.LastCommit.ID + + default: + errHandler(ctx, w, http.StatusInternalServerError, fmt.Errorf("unknown event type: %s", payload.EventType)) + } - mux.HandleFunc("POST /push", func(writer http.ResponseWriter, reader *http.Request) { - if reader.Header.Get("Content-Type") != "application/json" { - slog.Warn("not json") + ctx = slogctx.With(ctx, slog.String("event_type", payload.EventType), slog.String("merge_request_id", id), slog.String("sha_reference", ref)) - writer.WriteHeader(http.StatusInternalServerError) + // Get the remote config file + file, err := client.MergeRequests().GetRemoteConfig(ctx, cCtx.String(FlagConfigFile), ref) + if err != nil { + errHandler(ctx, w, http.StatusOK, fmt.Errorf("could not read remote config file: %w", err)) return } - var evt gitlab.PushEventPayload - if err := json.NewDecoder(reader.Body).Decode(&evt); err != nil { - writer.WriteHeader(http.StatusInternalServerError) + // Parse the file + cfg, err := config.ParseFile(file) + if err != nil { + errHandler(ctx, w, http.StatusOK, fmt.Errorf("could not parse config file: %w", err)) return } - writer.WriteHeader(http.StatusOK) - }) + // Decode request payload into 'any' so we have all the details + var fullEventPayload any + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&fullEventPayload); err != nil { + errHandler(ctx, w, http.StatusInternalServerError, err) + + return + } - log.Fatal(http.ListenAndServe("0.0.0.0:3000", mux)) //nolint:gosec + // Process the MR + if err := ProcessMR(ctx, client, cfg, id, fullEventPayload); err != nil { + errHandler(ctx, w, http.StatusOK, err) + + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) - return nil + server := &http.Server{ + Addr: cCtx.String(FlagServerListen), + Handler: http.Handler(mux), + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + BaseContext: func(l net.Listener) context.Context { + return cCtx.Context + }, + } + + return server.ListenAndServe() } diff --git a/cmd/conventions.go b/cmd/conventions.go index 7d75c25..9a33180 100644 --- a/cmd/conventions.go +++ b/cmd/conventions.go @@ -6,4 +6,6 @@ const ( FlagSCMProject = "project" FlagSCMBaseURL = "base-url" FlagMergeRequestID = "id" + FlagWebhookSecret = "webhook-secret" + FlagServerListen = "listen-port" ) diff --git a/cmd/shared.go b/cmd/shared.go index 87d325a..be3507c 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -2,26 +2,27 @@ package cmd import ( "context" - "fmt" + "log/slog" "net/http" "github.com/jippi/scm-engine/pkg/config" "github.com/jippi/scm-engine/pkg/scm" "github.com/jippi/scm-engine/pkg/state" + slogctx "github.com/veqryn/slog-context" ) -func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, mr string) error { +func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, mr string, event any) error { ctx = state.ContextWithMergeRequestID(ctx, mr) // for mr := 900; mr <= 1000; mr++ { - fmt.Println("Processing MR", mr) + slogctx.Info(ctx, "Processing MR") remoteLabels, err := client.Labels().List(ctx) if err != nil { return err } - fmt.Println("Creating evaluation context") + slogctx.Info(ctx, "Creating evaluation context") evalContext, err := client.EvalContext(ctx) if err != nil { @@ -29,26 +30,26 @@ func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, mr st } if evalContext == nil || !evalContext.IsValid() { - fmt.Println("Evaluating context is empty, does the Merge Request exists?") + slogctx.Warn(ctx, "Evaluating context is empty, does the Merge Request exists?") return nil } - fmt.Println("Evaluating context") + evalContext.SetWebhookEvent(event) + + slogctx.Info(ctx, "Evaluating context") labels, actions, err := cfg.Evaluate(evalContext) if err != nil { return err } - fmt.Println("Sync labels") + slogctx.Info(ctx, "Sync labels") if err := syncLabels(ctx, client, remoteLabels, labels); err != nil { return err } - fmt.Println("Done!") - var ( add scm.LabelOptions remove scm.LabelOptions @@ -67,22 +68,18 @@ func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, mr st RemoveLabels: &remove, } - fmt.Println("Applying actions") + slogctx.Info(ctx, "Applying actions") if err := runActions(ctx, client, update, actions); err != nil { return err } - fmt.Println("Done!") - - fmt.Println("Updating MR") + slogctx.Info(ctx, "Updating MR") if err := updateMergeRequest(ctx, client, update); err != nil { return err } - fmt.Println("Done!") - return nil } @@ -105,7 +102,7 @@ func runActions(ctx context.Context, client scm.Client, update *scm.UpdateMergeR } func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, required []scm.EvaluationResult) error { - fmt.Println("Going to sync", len(required), "required labels") + slogctx.Info(ctx, "Going to sync required labels", slog.Int("number_of_labels", len(required))) remoteLabels := map[string]*scm.Label{} for _, e := range remote { @@ -118,7 +115,7 @@ func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, req continue } - fmt.Print("Creating label ", label.Name, ": ") + slogctx.Info(ctx, "Creating label", slog.String("label", label.Name)) _, resp, err := client.Labels().Create(ctx, &scm.CreateLabelOptions{ Name: &label.Name, //nolint:gosec @@ -129,15 +126,13 @@ func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, req if err != nil { // Label already exists if resp.StatusCode == http.StatusConflict { - fmt.Println("Already exists!") + slogctx.Warn(ctx, "Label already exists", slog.String("label", label.Name)) continue } return err } - - fmt.Println("OK") } // Update @@ -151,7 +146,7 @@ func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, req continue } - fmt.Print("Updating label ", label.Name, ": ") + slogctx.Info(ctx, "Updating label", slog.String("label", label.Name)) _, _, err := client.Labels().Update(ctx, &scm.UpdateLabelOptions{ Name: &label.Name, //nolint:gosec @@ -162,8 +157,6 @@ func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, req if err != nil { return err } - - fmt.Println("OK") } return nil diff --git a/go.mod b/go.mod index d5cc336..0fa291d 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,23 @@ go 1.22.3 require ( github.com/99designs/gqlgen v0.17.46 + github.com/charmbracelet/lipgloss v0.10.0 github.com/davecgh/go-spew v1.1.1 github.com/expr-lang/expr v1.16.7 github.com/fatih/structtag v1.2.0 - github.com/go-playground/webhooks/v6 v6.3.0 + github.com/golang-cz/devslog v0.0.8 github.com/guregu/null/v5 v5.0.0 github.com/hasura/go-graphql-client v0.12.1 github.com/iancoleman/strcase v0.3.0 + github.com/lmittmann/tint v1.0.4 + github.com/muesli/termenv v0.15.2 + github.com/reugn/pkgslog v0.2.0 + github.com/samber/slog-multi v1.0.2 + github.com/teacat/noire v1.1.0 github.com/urfave/cli/v2 v2.27.2 github.com/vektah/gqlparser/v2 v2.5.11 + github.com/veqryn/slog-context v0.7.0 + github.com/veqryn/slog-dedup v0.5.0 github.com/xanzy/go-gitlab v0.104.1 github.com/xhit/go-str2duration/v2 v2.1.0 golang.org/x/oauth2 v0.20.0 @@ -21,18 +29,29 @@ require ( require ( github.com/agnivade/levenshtein v1.1.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/samber/lo v1.38.1 // indirect github.com/sosodev/duration v1.3.0 // indirect github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.15.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.20.0 // indirect + modernc.org/b/v2 v2.1.0 // indirect nhooyr.io/websocket v1.8.11 // indirect ) diff --git a/go.sum b/go.sum index 3aa2834..e519047 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,10 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,9 +21,10 @@ github.com/expr-lang/expr v1.16.7 h1:gCIiHt5ODA0xIaDbD0DPKyZpM9Drph3b3lolYAYq2Kw github.com/expr-lang/expr v1.16.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= -github.com/go-playground/webhooks/v6 v6.3.0 h1:zBLUxK1Scxwi97TmZt5j/B/rLlard2zY7P77FHg58FE= -github.com/go-playground/webhooks/v6 v6.3.0/go.mod h1:GCocmfMtpJdkEOM1uG9p2nXzg1kY5X/LtvQgtPHUaaA= -github.com/gogits/go-gogs-client v0.0.0-20200905025246-8bb8a50cb355/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/golang-cz/devslog v0.0.8 h1:53ipA2rC5JzWBWr9qB8EfenvXppenNiF/8DwgtNT5Q4= +github.com/golang-cz/devslog v0.0.8/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -43,12 +48,37 @@ github.com/hasura/go-graphql-client v0.12.1 h1:tL+BCoyubkYYyaQ+tJz+oPe/pSxYwOJHw github.com/hasura/go-graphql-client v0.12.1/go.mod h1:F4N4kR6vY8amio3gEu3tjSZr8GPOXJr3zj72DKixfLE= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= +github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/reugn/pkgslog v0.2.0 h1:Kedn37OrnOh+5cBxNNwrUHR7e8175CQLk8QztbPZ+VQ= +github.com/reugn/pkgslog v0.2.0/go.mod h1:Gb0SqIq+BCzAeTeWdHxiVz4S206U30WBd6gSsnjfMxo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/samber/slog-multi v1.0.2 h1:6BVH9uHGAsiGkbbtQgAOQJMpKgV8unMrHhhJaw+X1EQ= +github.com/samber/slog-multi v1.0.2/go.mod h1:uLAvHpGqbYgX4FSL0p1ZwoLuveIAJvBECtE07XmYvFo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sosodev/duration v1.3.0 h1:g3E6mto+hFdA2uZXeNDYff8LYeg7v5D4YKP/Ng/NUkE= @@ -58,22 +88,33 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg= +github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk= github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= +github.com/veqryn/slog-context v0.7.0 h1:Ne7ajlR6Mjs2rQQtpg8k0eO6krR5wzpareh5VpV+V2s= +github.com/veqryn/slog-context v0.7.0/go.mod h1:E+qpdyiQs2YKRxFnX1JjpdFE1z3Ka94Kem2q9ZG6Jjo= +github.com/veqryn/slog-dedup v0.5.0 h1:2pc4va3q8p7Tor1SjVvi1ZbVK/oKNPgsqG15XFEt0iM= +github.com/veqryn/slog-dedup v0.5.0/go.mod h1:/iQU008M3qFa5RovtfiHiODxJFvxZLjWRG/qf/zKFHw= github.com/xanzy/go-gitlab v0.104.1 h1:g/liXIPJH0jsTwVuzTAUMiKdTf6Qup3u2XZq5Rp90Wc= github.com/xanzy/go-gitlab v0.104.1/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= @@ -88,5 +129,11 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/b/v2 v2.1.0 h1:kMD/G43EYnsFJI/0qK1F1X659XlSs41bp01MUDidHC0= +modernc.org/b/v2 v2.1.0/go.mod h1:fQhHWDXrchyUSLjQYCslV/4uw04PW1LeiZ25D4SNmeo= +modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/main.go b/main.go index d308ac9..610a1b5 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,24 @@ package main import ( + "fmt" "io" "log" "os" "github.com/davecgh/go-spew/spew" "github.com/jippi/scm-engine/cmd" + "github.com/jippi/scm-engine/pkg/tui" "github.com/urfave/cli/v2" + slogctx "github.com/veqryn/slog-context" +) + +// nolint: gochecknoglobals +var ( + commit = "unknown" + date = "unknown" + treeState = "unknown" + version = "dev" ) func main() { @@ -19,12 +30,19 @@ func main() { Copyright: "Christian Winther", EnableBashCompletion: true, Suggest: true, + Version: fmt.Sprintf("%s (date: %s; commit: %s)", version, date, commit), Authors: []*cli.Author{ { Name: "Christian Winther", - Email: "gitlab-engine@jippi.dev", + Email: "scm-engine@jippi.dev", }, }, + Before: func(cCtx *cli.Context) error { + cCtx.Context = tui.NewContext(cCtx.Context, cCtx.App.Writer, cCtx.App.ErrWriter) + cCtx.Context = slogctx.With(cCtx.Context, "scm_engine_version", version) + + return nil + }, Flags: []cli.Flag{ &cli.StringFlag{ Name: cmd.FlagConfigFile, @@ -43,15 +61,7 @@ func main() { "SCM_ENGINE_TOKEN", }, }, - &cli.StringFlag{ - Name: cmd.FlagSCMProject, - Usage: "GitLab project (example: 'gitlab-org/gitlab')", - Required: true, - EnvVars: []string{ - "GITLAB_PROJECT", - "CI_PROJECT_PATH", - }, - }, + &cli.StringFlag{ Name: cmd.FlagSCMBaseURL, Usage: "Base URL for the SCM instance", @@ -68,6 +78,15 @@ func main() { Usage: "Evaluate a Merge Request", Action: cmd.Evaluate, Flags: []cli.Flag{ + &cli.StringFlag{ + Name: cmd.FlagSCMProject, + Usage: "GitLab project (example: 'gitlab-org/gitlab')", + Required: true, + EnvVars: []string{ + "GITLAB_PROJECT", + "CI_PROJECT_PATH", + }, + }, &cli.StringFlag{ Name: cmd.FlagMergeRequestID, Usage: "The pull/merge to process, if not provided as a CLI flag", @@ -85,6 +104,23 @@ func main() { Name: "server", Usage: "Start HTTP server for webhook event driven usage", Action: cmd.Server, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: cmd.FlagWebhookSecret, + Usage: "Used to validate received payloads. Sent with the request in the X-Gitlab-Token HTTP header", + EnvVars: []string{ + "SCM_ENGINE_WEBHOOK_SECRET", + }, + }, + &cli.StringFlag{ + Name: cmd.FlagServerListen, + Usage: "Port the HTTP server should listen on", + Value: "0.0.0.0:3000", + EnvVars: []string{ + "SCM_ENGINE_LISTEN", + }, + }, + }, }, }, } diff --git a/pkg/colors/colors.go b/pkg/colors/colors.go deleted file mode 100644 index bd64424..0000000 --- a/pkg/colors/colors.go +++ /dev/null @@ -1,134 +0,0 @@ -package colors - -import ( - "log" - "strings" -) - -var colors = map[string]string{ - "white": "#fff", - "black": "#000", - "blue": "#0D6EFD", - "blue-100": "#CFE2FF", - "blue-200": "#9EC5FE", - "blue-300": "#6EA8FE", - "blue-400": "#3D8BFD", - "blue-500": "#0D6EFD", - "blue-600": "#0A58CA", - "blue-700": "#084298", - "blue-800": "#052C65", - "blue-900": "#031633", - "indigo": "#6610F2", - "indigo-100": "#E0CFFC", - "indigo-200": "#C29FFA", - "indigo-300": "#A370F7", - "indigo-400": "#8540F5", - "indigo-500": "#6610F2", - "indigo-600": "#520DC2", - "indigo-700": "#3D0A91", - "indigo-800": "#290661", - "indigo-900": "#140330", - "purple": "#6F42C1", - "purple-100": "#E2D9F3", - "purple-200": "#C5B3E6", - "purple-300": "#A98EDA", - "purple-400": "#8C68CD", - "purple-500": "#6F42C1", - "purple-600": "#59359A", - "purple-700": "#432874", - "purple-800": "#2C1A4D", - "purple-900": "#160D27", - "pink": "#D63384", - "pink-100": "#F7D6E6", - "pink-200": "#EFADCE", - "pink-300": "#E685B5", - "pink-400": "#DE5C9D", - "pink-500": "#D63384", - "pink-600": "#AB296A", - "pink-700": "#801F4F", - "pink-800": "#561435", - "pink-900": "#2B0A1A", - "red": "#DC3545", - "red-100": "#F8D7DA", - "red-200": "#F1AEB5", - "red-300": "#EA868F", - "red-400": "#E35D6A", - "red-500": "#DC3545", - "red-600": "#B02A37", - "red-700": "#842029", - "red-800": "#58151C", - "red-900": "#2C0B0E", - "orange": "#FD7E14", - "orange-100": "#FFE5D0", - "orange-200": "#FECBA1", - "orange-300": "#FEB272", - "orange-400": "#FD9843", - "orange-500": "#FD7E14", - "orange-600": "#CA6510", - "orange-700": "#984C0C", - "orange-800": "#653208", - "orange-900": "#331904", - "yellow": "#FFC107", - "yellow-100": "#FFF3CD", - "yellow-200": "#FFE69C", - "yellow-300": "#FFDA6A", - "yellow-400": "#FFCD39", - "yellow-500": "#FFC107", - "yellow-600": "#CC9A06", - "yellow-700": "#997404", - "yellow-800": "#664D03", - "yellow-900": "#332701", - "green": "#198754", - "green-100": "#D1E7DD", - "green-200": "#A3CFBB", - "green-300": "#75B798", - "green-400": "#479F76", - "green-500": "#198754", - "green-600": "#146C43", - "green-700": "#0F5132", - "green-800": "#0A3622", - "green-900": "#051B11", - "teal": "#20C997", - "teal-100": "#D2F4EA", - "teal-200": "#A6E9D5", - "teal-300": "#79DFC1", - "teal-400": "#4DD4AC", - "teal-500": "#20C997", - "teal-600": "#1AA179", - "teal-700": "#13795B", - "teal-800": "#0D503C", - "teal-900": "#06281E", - "cyan": "#0DCAF0", - "cyan-100": "#CFF4FC", - "cyan-200": "#9EEAF9", - "cyan-300": "#6EDFF6", - "cyan-400": "#3DD5F3", - "cyan-500": "#0DCAF0", - "cyan-600": "#0AA2C0", - "cyan-700": "#087990", - "cyan-800": "#055160", - "cyan-900": "#032830", - "gray": "#ADB5BD", - "gray-100": "#EFF0F2", - "gray-200": "#DEE1E5", - "gray-300": "#CED3D7", - "gray-400": "#BDC4CA", - "gray-500": "#ADB5BD", - "gray-600": "#8A9197", - "gray-700": "#686D71", - "gray-800": "#45484C", - "gray-900": "#232426", -} - -func Replace(color string) string { - if strings.HasPrefix(color, "$") { - v, ok := colors[strings.TrimPrefix(color, "$")] - if !ok { - log.Fatalf("Unknown color: %q", color) - } - - return v - } - - return color -} diff --git a/pkg/config/label.go b/pkg/config/label.go index 8ed5de9..b3be3c1 100644 --- a/pkg/config/label.go +++ b/pkg/config/label.go @@ -7,9 +7,9 @@ import ( "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" - "github.com/jippi/scm-engine/pkg/colors" "github.com/jippi/scm-engine/pkg/scm" "github.com/jippi/scm-engine/pkg/stdlib" + "github.com/jippi/scm-engine/pkg/tui" "github.com/jippi/scm-engine/pkg/types" ) @@ -144,7 +144,7 @@ func (p *Label) initialize(evalContext scm.EvalContext) error { var err error if p.scriptCompiled == nil { - p.Color = colors.Replace(p.Color) + p.Color = tui.Replace(p.Color) opts := []expr.Option{} opts = append(opts, scriptReturnType) @@ -159,7 +159,7 @@ func (p *Label) initialize(evalContext scm.EvalContext) error { } if p.skipIfCompiled == nil && len(p.SkipIf) > 0 { - p.Color = colors.Replace(p.Color) + p.Color = tui.Replace(p.Color) opts := []expr.Option{} opts = append(opts, expr.AsBool()) diff --git a/pkg/scm/gitlab/client_label.go b/pkg/scm/gitlab/client_label.go index 5456b44..043096f 100644 --- a/pkg/scm/gitlab/client_label.go +++ b/pkg/scm/gitlab/client_label.go @@ -3,10 +3,12 @@ package gitlab import ( "context" "fmt" + "log/slog" "net/http" "github.com/jippi/scm-engine/pkg/scm" "github.com/jippi/scm-engine/pkg/state" + slogctx "github.com/veqryn/slog-context" go_gitlab "github.com/xanzy/go-gitlab" ) @@ -40,7 +42,7 @@ func (client *LabelClient) List(ctx context.Context) ([]*scm.Label, error) { } for { - fmt.Println("Reading labels page", opts.Page) + slogctx.Info(ctx, "Reading labels page", slog.Int("page", opts.Page)) labels, resp, err := client.list(ctx, opts) if err != nil { diff --git a/pkg/scm/gitlab/client_merge_request.go b/pkg/scm/gitlab/client_merge_request.go index f8e9c76..372c2d9 100644 --- a/pkg/scm/gitlab/client_merge_request.go +++ b/pkg/scm/gitlab/client_merge_request.go @@ -1,8 +1,10 @@ package gitlab import ( + "bytes" "context" "fmt" + "io" "net/http" "github.com/hasura/go-graphql-client" @@ -46,6 +48,20 @@ func (client *MergeRequestClient) Update(ctx context.Context, opt *scm.UpdateMer return convertResponse(resp), err } +func (client *MergeRequestClient) GetRemoteConfig(ctx context.Context, filename, ref string) (io.Reader, error) { + project, err := ParseID(state.ProjectIDFromContext(ctx)) + if err != nil { + return nil, err + } + + file, _, err := client.client.wrapped.RepositoryFiles.GetRawFile(project, filename, &go_gitlab.GetRawFileOptions{Ref: go_gitlab.Ptr(ref)}) + if err != nil { + return nil, err + } + + return bytes.NewReader(file), nil +} + func (client *MergeRequestClient) List(ctx context.Context, options *scm.ListMergeRequestsOptions) ([]scm.ListMergeRequest, error) { httpClient := oauth2.NewClient( ctx, diff --git a/pkg/scm/gitlab/context.go b/pkg/scm/gitlab/context.go index 7d1011c..723ddcd 100644 --- a/pkg/scm/gitlab/context.go +++ b/pkg/scm/gitlab/context.go @@ -83,3 +83,7 @@ func NewContext(ctx context.Context, baseURL, token string) (*Context, error) { func (c *Context) IsValid() bool { return c != nil } + +func (c *Context) SetWebhookEvent(in any) { + c.WebhookEvent = in +} diff --git a/pkg/scm/interfaces.go b/pkg/scm/interfaces.go index e6801b9..7ba9140 100644 --- a/pkg/scm/interfaces.go +++ b/pkg/scm/interfaces.go @@ -2,6 +2,7 @@ package scm import ( "context" + "io" ) type Client interface { @@ -20,10 +21,12 @@ type LabelClient interface { type MergeRequestClient interface { Update(ctx context.Context, opt *UpdateMergeRequestOptions) (*Response, error) List(ctx context.Context, options *ListMergeRequestsOptions) ([]ListMergeRequest, error) + GetRemoteConfig(ctx context.Context, name string, ref string) (io.Reader, error) } type EvalContext interface { IsValid() bool + SetWebhookEvent(in any) } type EvalContextualizer struct{} diff --git a/pkg/state/context.go b/pkg/state/context.go index 58d6c48..36c5c18 100644 --- a/pkg/state/context.go +++ b/pkg/state/context.go @@ -3,6 +3,8 @@ package state import ( "context" "strconv" + + slogctx "github.com/veqryn/slog-context" ) type contextKey uint @@ -25,7 +27,17 @@ func ProjectIDFromContext(ctx context.Context) string { } func ContextWithProjectID(ctx context.Context, value string) context.Context { - return context.WithValue(ctx, projectID, value) + ctx = slogctx.With(ctx, "project_id", value) + ctx = context.WithValue(ctx, projectID, value) + + return ctx +} + +func ContextWithMergeRequestID(ctx context.Context, id string) context.Context { + ctx = slogctx.With(ctx, "merge_request_id", id) + ctx = context.WithValue(ctx, mergeRequestID, id) + + return ctx } func MergeRequestIDFromContext(ctx context.Context) string { @@ -42,7 +54,3 @@ func MergeRequestIDFromContextInt(ctx context.Context) int { return number } - -func ContextWithMergeRequestID(ctx context.Context, id string) context.Context { - return context.WithValue(ctx, mergeRequestID, id) -} diff --git a/pkg/tui/colors.go b/pkg/tui/colors.go new file mode 100644 index 0000000..9c99d02 --- /dev/null +++ b/pkg/tui/colors.go @@ -0,0 +1,706 @@ +package tui + +import ( + "log" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type ColorPair struct { + Name string + Value lipgloss.Color +} + +const ( + White = lipgloss.Color("#fff") + Black = lipgloss.Color("#000") + + Blue = lipgloss.Color("#0D6EFD") + Blue100 = lipgloss.Color("#CFE2FF") + Blue200 = lipgloss.Color("#9EC5FE") + Blue300 = lipgloss.Color("#6EA8FE") + Blue400 = lipgloss.Color("#3D8BFD") + Blue500 = lipgloss.Color("#0D6EFD") + Blue600 = lipgloss.Color("#0A58CA") + Blue700 = lipgloss.Color("#084298") + Blue800 = lipgloss.Color("#052C65") + Blue900 = lipgloss.Color("#031633") + Indigo = lipgloss.Color("#6610F2") + Indigo100 = lipgloss.Color("#E0CFFC") + Indigo200 = lipgloss.Color("#C29FFA") + Indigo300 = lipgloss.Color("#A370F7") + Indigo400 = lipgloss.Color("#8540F5") + Indigo500 = lipgloss.Color("#6610F2") + Indigo600 = lipgloss.Color("#520DC2") + Indigo700 = lipgloss.Color("#3D0A91") + Indigo800 = lipgloss.Color("#290661") + Indigo900 = lipgloss.Color("#140330") + Purple = lipgloss.Color("#6F42C1") + Purple100 = lipgloss.Color("#E2D9F3") + Purple200 = lipgloss.Color("#C5B3E6") + Purple300 = lipgloss.Color("#A98EDA") + Purple400 = lipgloss.Color("#8C68CD") + Purple500 = lipgloss.Color("#6F42C1") + Purple600 = lipgloss.Color("#59359A") + Purple700 = lipgloss.Color("#432874") + Purple800 = lipgloss.Color("#2C1A4D") + Purple900 = lipgloss.Color("#160D27") + Pink = lipgloss.Color("#D63384") + Pink100 = lipgloss.Color("#F7D6E6") + Pink200 = lipgloss.Color("#EFADCE") + Pink300 = lipgloss.Color("#E685B5") + Pink400 = lipgloss.Color("#DE5C9D") + Pink500 = lipgloss.Color("#D63384") + Pink600 = lipgloss.Color("#AB296A") + Pink700 = lipgloss.Color("#801F4F") + Pink800 = lipgloss.Color("#561435") + Pink900 = lipgloss.Color("#2B0A1A") + Red = lipgloss.Color("#DC3545") + Red100 = lipgloss.Color("#F8D7DA") + Red200 = lipgloss.Color("#F1AEB5") + Red300 = lipgloss.Color("#EA868F") + Red400 = lipgloss.Color("#E35D6A") + Red500 = lipgloss.Color("#DC3545") + Red600 = lipgloss.Color("#B02A37") + Red700 = lipgloss.Color("#842029") + Red800 = lipgloss.Color("#58151C") + Red900 = lipgloss.Color("#2C0B0E") + Orange = lipgloss.Color("#FD7E14") + Orange100 = lipgloss.Color("#FFE5D0") + Orange200 = lipgloss.Color("#FECBA1") + Orange300 = lipgloss.Color("#FEB272") + Orange400 = lipgloss.Color("#FD9843") + Orange500 = lipgloss.Color("#FD7E14") + Orange600 = lipgloss.Color("#CA6510") + Orange700 = lipgloss.Color("#984C0C") + Orange800 = lipgloss.Color("#653208") + Orange900 = lipgloss.Color("#331904") + Yellow = lipgloss.Color("#FFC107") + Yellow100 = lipgloss.Color("#FFF3CD") + Yellow200 = lipgloss.Color("#FFE69C") + Yellow300 = lipgloss.Color("#FFDA6A") + Yellow400 = lipgloss.Color("#FFCD39") + Yellow500 = lipgloss.Color("#FFC107") + Yellow600 = lipgloss.Color("#CC9A06") + Yellow700 = lipgloss.Color("#997404") + Yellow800 = lipgloss.Color("#664D03") + Yellow900 = lipgloss.Color("#332701") + Green = lipgloss.Color("#198754") + Green100 = lipgloss.Color("#D1E7DD") + Green200 = lipgloss.Color("#A3CFBB") + Green300 = lipgloss.Color("#75B798") + Green400 = lipgloss.Color("#479F76") + Green500 = lipgloss.Color("#198754") + Green600 = lipgloss.Color("#146C43") + Green700 = lipgloss.Color("#0F5132") + Green800 = lipgloss.Color("#0A3622") + Green900 = lipgloss.Color("#051B11") + Teal = lipgloss.Color("#20C997") + Teal100 = lipgloss.Color("#D2F4EA") + Teal200 = lipgloss.Color("#A6E9D5") + Teal300 = lipgloss.Color("#79DFC1") + Teal400 = lipgloss.Color("#4DD4AC") + Teal500 = lipgloss.Color("#20C997") + Teal600 = lipgloss.Color("#1AA179") + Teal700 = lipgloss.Color("#13795B") + Teal800 = lipgloss.Color("#0D503C") + Teal900 = lipgloss.Color("#06281E") + Cyan = lipgloss.Color("#0DCAF0") + Cyan100 = lipgloss.Color("#CFF4FC") + Cyan200 = lipgloss.Color("#9EEAF9") + Cyan300 = lipgloss.Color("#6EDFF6") + Cyan400 = lipgloss.Color("#3DD5F3") + Cyan500 = lipgloss.Color("#0DCAF0") + Cyan600 = lipgloss.Color("#0AA2C0") + Cyan700 = lipgloss.Color("#087990") + Cyan800 = lipgloss.Color("#055160") + Cyan900 = lipgloss.Color("#032830") + Gray = lipgloss.Color("#ADB5BD") + Gray100 = lipgloss.Color("#EFF0F2") + Gray200 = lipgloss.Color("#DEE1E5") + Gray300 = lipgloss.Color("#CED3D7") + Gray400 = lipgloss.Color("#BDC4CA") + Gray500 = lipgloss.Color("#ADB5BD") + Gray600 = lipgloss.Color("#8A9197") + Gray700 = lipgloss.Color("#686D71") + Gray800 = lipgloss.Color("#45484C") + Gray900 = lipgloss.Color("#232426") +) + +var ( + BlueFamily = []ColorPair{ + { + Name: "Blue100", + Value: Blue100, + }, + { + Name: "Blue200", + Value: Blue200, + }, + { + Name: "Blue300", + Value: Blue300, + }, + { + Name: "Blue400", + Value: Blue400, + }, + { + Name: "Blue500", + Value: Blue500, + }, + { + Name: "Blue600", + Value: Blue600, + }, + { + Name: "Blue700", + Value: Blue700, + }, + { + Name: "Blue800", + Value: Blue800, + }, + { + Name: "Blue900", + Value: Blue900, + }, + } + IndigoFamily = []ColorPair{ + { + Name: "Indigo100", + Value: Indigo100, + }, + { + Name: "Indigo200", + Value: Indigo200, + }, + { + Name: "Indigo300", + Value: Indigo300, + }, + { + Name: "Indigo400", + Value: Indigo400, + }, + { + Name: "Indigo500", + Value: Indigo500, + }, + { + Name: "Indigo600", + Value: Indigo600, + }, + { + Name: "Indigo700", + Value: Indigo700, + }, + { + Name: "Indigo800", + Value: Indigo800, + }, + { + Name: "Indigo900", + Value: Indigo900, + }, + } + PurpleFamily = []ColorPair{ + { + Name: "Purple100", + Value: Purple100, + }, + { + Name: "Purple200", + Value: Purple200, + }, + { + Name: "Purple300", + Value: Purple300, + }, + { + Name: "Purple400", + Value: Purple400, + }, + { + Name: "Purple500", + Value: Purple500, + }, + { + Name: "Purple600", + Value: Purple600, + }, + { + Name: "Purple700", + Value: Purple700, + }, + { + Name: "Purple800", + Value: Purple800, + }, + { + Name: "Purple900", + Value: Purple900, + }, + } + PinkFamily = []ColorPair{ + { + Name: "Pink100", + Value: Pink100, + }, + { + Name: "Pink200", + Value: Pink200, + }, + { + Name: "Pink300", + Value: Pink300, + }, + { + Name: "Pink400", + Value: Pink400, + }, + { + Name: "Pink500", + Value: Pink500, + }, + { + Name: "Pink600", + Value: Pink600, + }, + { + Name: "Pink700", + Value: Pink700, + }, + { + Name: "Pink800", + Value: Pink800, + }, + { + Name: "Pink900", + Value: Pink900, + }, + } + RedFamily = []ColorPair{ + { + Name: "Red100", + Value: Red100, + }, + { + Name: "Red200", + Value: Red200, + }, + { + Name: "Red300", + Value: Red300, + }, + { + Name: "Red400", + Value: Red400, + }, + { + Name: "Red500", + Value: Red500, + }, + { + Name: "Red600", + Value: Red600, + }, + { + Name: "Red700", + Value: Red700, + }, + { + Name: "Red800", + Value: Red800, + }, + { + Name: "Red900", + Value: Red900, + }, + } + OrangeFamily = []ColorPair{ + { + Name: "Orange100", + Value: Orange100, + }, + { + Name: "Orange200", + Value: Orange200, + }, + { + Name: "Orange300", + Value: Orange300, + }, + { + Name: "Orange400", + Value: Orange400, + }, + { + Name: "Orange500", + Value: Orange500, + }, + { + Name: "Orange600", + Value: Orange600, + }, + { + Name: "Orange700", + Value: Orange700, + }, + { + Name: "Orange800", + Value: Orange800, + }, + { + Name: "Orange900", + Value: Orange900, + }, + } + YellowFamily = []ColorPair{ + { + Name: "Yellow100", + Value: Yellow100, + }, + { + Name: "Yellow200", + Value: Yellow200, + }, + { + Name: "Yellow300", + Value: Yellow300, + }, + { + Name: "Yellow400", + Value: Yellow400, + }, + { + Name: "Yellow500", + Value: Yellow500, + }, + { + Name: "Yellow600", + Value: Yellow600, + }, + { + Name: "Yellow700", + Value: Yellow700, + }, + { + Name: "Yellow800", + Value: Yellow800, + }, + { + Name: "Yellow900", + Value: Yellow900, + }, + } + GreenFamily = []ColorPair{ + { + Name: "Green100", + Value: Green100, + }, + { + Name: "Green200", + Value: Green200, + }, + { + Name: "Green300", + Value: Green300, + }, + { + Name: "Green400", + Value: Green400, + }, + { + Name: "Green500", + Value: Green500, + }, + { + Name: "Green600", + Value: Green600, + }, + { + Name: "Green700", + Value: Green700, + }, + { + Name: "Green800", + Value: Green800, + }, + { + Name: "Green900", + Value: Green900, + }, + } + TealFamily = []ColorPair{ + { + Name: "Teal100", + Value: Teal100, + }, + { + Name: "Teal200", + Value: Teal200, + }, + { + Name: "Teal300", + Value: Teal300, + }, + { + Name: "Teal400", + Value: Teal400, + }, + { + Name: "Teal500", + Value: Teal500, + }, + { + Name: "Teal600", + Value: Teal600, + }, + { + Name: "Teal700", + Value: Teal700, + }, + { + Name: "Teal800", + Value: Teal800, + }, + { + Name: "Teal900", + Value: Teal900, + }, + } + CyanFamily = []ColorPair{ + { + Name: "Cyan100", + Value: Cyan100, + }, + { + Name: "Cyan200", + Value: Cyan200, + }, + { + Name: "Cyan300", + Value: Cyan300, + }, + { + Name: "Cyan400", + Value: Cyan400, + }, + { + Name: "Cyan500", + Value: Cyan500, + }, + { + Name: "Cyan600", + Value: Cyan600, + }, + { + Name: "Cyan700", + Value: Cyan700, + }, + { + Name: "Cyan800", + Value: Cyan800, + }, + { + Name: "Cyan900", + Value: Cyan900, + }, + } + GrayFamily = []ColorPair{ + { + Name: "Gray100", + Value: Gray100, + }, + { + Name: "Gray200", + Value: Gray200, + }, + { + Name: "Gray300", + Value: Gray300, + }, + { + Name: "Gray400", + Value: Gray400, + }, + { + Name: "Gray500", + Value: Gray500, + }, + { + Name: "Gray600", + Value: Gray600, + }, + { + Name: "Gray700", + Value: Gray700, + }, + { + Name: "Gray800", + Value: Gray800, + }, + { + Name: "Gray900", + Value: Gray900, + }, + } + + ColorsFamilies = []string{ + "Blue", + "Indigo", + "Purple", + "Pink", + "Red", + "Orange", + "Yellow", + "Green", + "Teal", + "Cyan", + "Gray", + } + + // All colors, grouped by their family + ColorsByFamily = map[string][]ColorPair{ + "Blue": BlueFamily, + "Indigo": IndigoFamily, + "Purple": PurpleFamily, + "Pink": PinkFamily, + "Red": RedFamily, + "Orange": OrangeFamily, + "Yellow": YellowFamily, + "Green": GreenFamily, + "Teal": TealFamily, + "Cyan": CyanFamily, + "Gray": GrayFamily, + } + + // All known colors in a map to easily look up their name to value + AllColors = map[string]lipgloss.Color{ + "blue": Blue, + "blue-100": Blue100, + "blue-200": Blue200, + "blue-300": Blue300, + "blue-400": Blue400, + "blue-500": Blue500, + "blue-600": Blue600, + "blue-700": Blue700, + "blue-800": Blue800, + "blue-900": Blue900, + "indigo": Indigo, + "indigo-100": Indigo100, + "indigo-200": Indigo200, + "indigo-300": Indigo300, + "indigo-400": Indigo400, + "indigo-500": Indigo500, + "indigo-600": Indigo600, + "indigo-700": Indigo700, + "indigo-800": Indigo800, + "indigo-900": Indigo900, + "purple": Purple, + "purple-100": Purple100, + "purple-200": Purple200, + "purple-300": Purple300, + "purple-400": Purple400, + "purple-500": Purple500, + "purple-600": Purple600, + "purple-700": Purple700, + "purple-800": Purple800, + "purple-900": Purple900, + "pink": Pink, + "pink-100": Pink100, + "pink-200": Pink200, + "pink-300": Pink300, + "pink-400": Pink400, + "pink-500": Pink500, + "pink-600": Pink600, + "pink-700": Pink700, + "pink-800": Pink800, + "pink-900": Pink900, + "red": Red, + "red-100": Red100, + "red-200": Red200, + "red-300": Red300, + "red-400": Red400, + "red-500": Red500, + "red-600": Red600, + "red-700": Red700, + "red-800": Red800, + "red-900": Red900, + "orange": Orange, + "orange-100": Orange100, + "orange-200": Orange200, + "orange-300": Orange300, + "orange-400": Orange400, + "orange-500": Orange500, + "orange-600": Orange600, + "orange-700": Orange700, + "orange-800": Orange800, + "orange-900": Orange900, + "yellow": Yellow, + "yellow-100": Yellow100, + "yellow-200": Yellow200, + "yellow-300": Yellow300, + "yellow-400": Yellow400, + "yellow-500": Yellow500, + "yellow-600": Yellow600, + "yellow-700": Yellow700, + "yellow-800": Yellow800, + "yellow-900": Yellow900, + "green": Green, + "green-100": Green100, + "green-200": Green200, + "green-300": Green300, + "green-400": Green400, + "green-500": Green500, + "green-600": Green600, + "green-700": Green700, + "green-800": Green800, + "green-900": Green900, + "teal": Teal, + "teal-100": Teal100, + "teal-200": Teal200, + "teal-300": Teal300, + "teal-400": Teal400, + "teal-500": Teal500, + "teal-600": Teal600, + "teal-700": Teal700, + "teal-800": Teal800, + "teal-900": Teal900, + "cyan": Cyan, + "cyan-100": Cyan100, + "cyan-200": Cyan200, + "cyan-300": Cyan300, + "cyan-400": Cyan400, + "cyan-500": Cyan500, + "cyan-600": Cyan600, + "cyan-700": Cyan700, + "cyan-800": Cyan800, + "cyan-900": Cyan900, + "gray": Gray, + "gray-100": Gray100, + "gray-200": Gray200, + "gray-300": Gray300, + "gray-400": Gray400, + "gray-500": Gray500, + "gray-600": Gray600, + "gray-700": Gray700, + "gray-800": Gray800, + "gray-900": Gray900, + } +) + +func Replace(color string) string { + if strings.HasPrefix(color, "$") { + v, ok := AllColors[strings.TrimPrefix(color, "$")] + if !ok { + log.Fatalf("Unknown color: %q", color) + } + + return string(v) + } + + return color +} diff --git a/pkg/tui/context.go b/pkg/tui/context.go new file mode 100644 index 0000000..bdd3ac4 --- /dev/null +++ b/pkg/tui/context.go @@ -0,0 +1,94 @@ +package tui + +import ( + "context" + "io" + "log/slog" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/reugn/pkgslog" + slogmulti "github.com/samber/slog-multi" + slogctx "github.com/veqryn/slog-context" + slogdedup "github.com/veqryn/slog-dedup" +) + +type fileDescriptorKey int + +const ( + Stdout fileDescriptorKey = iota + Stderr +) + +type contextKey int + +const ( + themeContextValue contextKey = iota + colorProfileContextValue +) + +func NewContext(ctx context.Context, stdout, stderr io.Writer) context.Context { + ctx = NewContextWithoutLogger(ctx, stdout, stderr) + ctx = slogctx.NewCtx( + ctx, + slog.New( + slogmulti. + Pipe( + func(next slog.Handler) slog.Handler { + return pkgslog.NewPackageHandler(next, packageLogLevels()) + }, + ). + Pipe( + slogctx.NewMiddleware(&slogctx.HandlerOptions{}), + ). + Pipe( + slogdedup.NewOverwriteMiddleware(&slogdedup.OverwriteHandlerOptions{ + ResolveKey: slogdedup.KeepIfBuiltinKeyConflict, + }), + ). + Handler( + logHandler(stderr), + ), + ), + ) + + return ctx +} + +func NewContextWithoutLogger(ctx context.Context, stdout, stderr io.Writer) context.Context { + theme := NewTheme() + + stdoutOutput := lipgloss.NewRenderer(stdout, termenv.WithColorCache(true)) + stderrOutput := lipgloss.NewRenderer(stderr, termenv.WithColorCache(true)) + + ctx = context.WithValue(ctx, themeContextValue, theme) + ctx = context.WithValue(ctx, colorProfileContextValue, stdoutOutput.ColorProfile()) + ctx = context.WithValue(ctx, Stdout, theme.Writer(stdoutOutput)) + ctx = context.WithValue(ctx, Stderr, theme.Writer(stderrOutput)) + + return ctx +} + +func ThemeFromContext(ctx context.Context) Theme { + return ctx.Value(themeContextValue).(Theme) //nolint:forcetypeassert +} + +func ColorProfileFromContext(ctx context.Context) termenv.Profile { + return ctx.Value(colorProfileContextValue).(termenv.Profile) //nolint:forcetypeassert +} + +func WriterFromContext(ctx context.Context, descriptor fileDescriptorKey) Writer { + return ctx.Value(descriptor).(Writer) //nolint:forcetypeassert +} + +func StdoutFromContext(ctx context.Context) Writer { + return WriterFromContext(ctx, Stdout) +} + +func StderrFromContext(ctx context.Context) Writer { + return WriterFromContext(ctx, Stderr) +} + +func WritersFromContext(ctx context.Context) (Writer, Writer) { + return StdoutFromContext(ctx), StderrFromContext(ctx) +} diff --git a/pkg/tui/conventions.go b/pkg/tui/conventions.go new file mode 100644 index 0000000..cc684db --- /dev/null +++ b/pkg/tui/conventions.go @@ -0,0 +1,40 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +const borderWidth = 2 + +var ( + headerBorder = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "┌", + TopRight: "┐", + BottomLeft: "├", + BottomRight: "┤", + } + + headerOnlyBorder = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "┌", + TopRight: "┐", + BottomLeft: "└", + BottomRight: "┘", + } + + bodyBorder = lipgloss.Border{ + Top: "", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "", + TopRight: "", + BottomLeft: "└", + BottomRight: "┘", + } +) diff --git a/pkg/tui/helpers.go b/pkg/tui/helpers.go new file mode 100644 index 0000000..5d96dea --- /dev/null +++ b/pkg/tui/helpers.go @@ -0,0 +1,42 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/teacat/noire" +) + +func ShadeColor(in string, percent float64) lipgloss.Color { + if percent < 0 || percent > 1 { + panic("ShadeColor [percent] must be between 0.0 and 1.0 (0.5 == 50%)") + } + + return lipgloss.Color("#" + noire.NewHex(in).Shade(percent).Hex()) +} + +func TintColor(in string, percent float64) lipgloss.Color { + if percent < 0 || percent > 1 { + panic("TintColor [percent] must be between 0.0 and 1.0 (0.5 == 50%)") + } + + return lipgloss.Color("#" + noire.NewHex(in).Tint(percent).Hex()) +} + +func ColorToHex(in lipgloss.Color) string { + return string(in) +} + +func TransformColor(base, filter string, percent float64) string { + switch filter { + case "shade": + return ColorToHex(ShadeColor(base, percent)) + + case "tint": + return ColorToHex(TintColor(base, percent)) + + case "mix": + panic("unexpected mix filter") + + default: + return base + } +} diff --git a/pkg/tui/logger.go b/pkg/tui/logger.go new file mode 100644 index 0000000..2ff442c --- /dev/null +++ b/pkg/tui/logger.go @@ -0,0 +1,91 @@ +package tui + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" + + "github.com/golang-cz/devslog" + "github.com/lmittmann/tint" +) + +const pkgPrefix = "github.com/jippi/dottie" + +func ParseLogLevel(name string, fallback slog.Level) slog.Level { + switch strings.ToUpper(name) { + case "DEBUG": + return slog.LevelDebug + + case "INFO": + return slog.LevelInfo + + case "WARN": + return slog.LevelWarn + + case "ERROR": + return slog.LevelError + + default: + return fallback + } +} + +func pkgLogLevel(name string, fallback slog.Level) slog.Level { + return ParseLogLevel(os.Getenv(name+"_LOG_LEVEL"), fallback) +} + +func packageLogLevels() map[string]slog.Level { + logLevel := ParseLogLevel(os.Getenv("LOG_LEVEL"), slog.LevelInfo) + + lowestOf := func(in slog.Level) slog.Level { + if in < logLevel { + return in + } + + return logLevel + } + + return map[string]slog.Level{ + pkgPrefix + "/pkg/parser": pkgLogLevel("PARSER", lowestOf(slog.LevelWarn)), + pkgPrefix + "/pkg/scanner": pkgLogLevel("SCANNER", lowestOf(slog.LevelWarn)), + } +} + +func logHandler(out io.Writer) slog.Handler { + logLevel := ParseLogLevel(os.Getenv("LOG_LEVEL"), slog.LevelInfo) + + if _, ok := os.LookupEnv("CI"); ok { + return tint.NewHandler( + out, + &tint.Options{ + Level: logLevel, + AddSource: logLevel == slog.LevelDebug, + }, + ) + } + + return devslog.NewHandler( + out, + &devslog.Options{ + SortKeys: true, + HandlerOptions: &slog.HandlerOptions{ + Level: logLevel, + AddSource: logLevel == slog.LevelDebug, + }, + }, + ) +} + +func StringDump(key, value string) slog.Attr { + return slog.Group( + key, + slog.String("Raw", value), + slog.String("Glyph", fmt.Sprintf("%q", value)), + slog.String("UTF-8", fmt.Sprintf("% x", []rune(value))), + slog.String("Unicode", fmt.Sprintf("%U", []rune(value))), + slog.String("[]rune", fmt.Sprintf("%v", []rune(value))), + slog.String("[]byte", fmt.Sprintf("%v", []byte(value))), + ) +} diff --git a/pkg/tui/printer.go b/pkg/tui/printer.go new file mode 100644 index 0000000..7416148 --- /dev/null +++ b/pkg/tui/printer.go @@ -0,0 +1,319 @@ +package tui + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type StyleChanger func(*lipgloss.Style) + +var Bold = func(s *lipgloss.Style) { + s.Bold(true) +} + +type PrinterOption func(p *Printer) + +// Printer mirrors the [fmt] package print/sprint functions, wraps them in a [lipgloss.Style] +// and an optional [WordWrap] configuration with a configured [BoxWidth]. +// +// Additionally, [Printer*] methods writes to the configured [Writer] instead of [os.Stdout] +type Printer struct { + boxWidth int // Max width for strings when using WrapMode + writer io.Writer // Writer controls where implicit print output goes for [Print], [Printf], [Printfln] and [Println] + renderer *lipgloss.Renderer // The renderer responsible for providing the output and color management + style Style // Style config + textStyle lipgloss.Style + boxHeaderStyle lipgloss.Style + boxBodyStyle lipgloss.Style +} + +func NewPrinter(style Style, renderer *lipgloss.Renderer, options ...PrinterOption) Printer { + options = append([]PrinterOption{ + WitBoxWidth(80), + WithStyle(style), + WithRenderer(renderer), + }, options...) + + printer := &Printer{} + for _, option := range options { + option(printer) + } + + printer.boxHeaderStyle = style.BoxHeader() + printer.boxBodyStyle = style.BoxBody() + + return *printer +} + +// ---------------------------------------- +// print to a specific io.Writer +// ---------------------------------------- + +// Fprint mirrors [fmt.Fprint] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprint(w io.Writer, a ...any) (n int, err error) { + return fmt.Fprint(w, p.Sprint(a...)) +} + +// Fprintf mirrors [fmt.Fprintf] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprintf(w io.Writer, format string, a ...any) (n int, err error) { + return p.Fprint(w, p.Sprintf(format, a...)) +} + +// Fprintfln mirrors [fmt.Fprintfln] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprintfln(w io.Writer, format string, a ...any) (n int, err error) { + return p.Fprintln(w, p.Sprintf(format, a...)) +} + +// Fprintln mirrors [fmt.Fprintln] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprintln(w io.Writer, a ...any) (n int, err error) { + return fmt.Fprintln(w, p.printHelper(a...)) +} + +// ----------------------------------------------------- +// Print to the default [p.writer] over [os.Stdout] +// ----------------------------------------------------- + +// Print mirrors [fmt.Print] signature and behavior, with the configured style +// and (optional) word wrapping applied. +// +// Instead of writing to [os.Stdout] it will write to the configured [io.Writer]. +func (p Printer) Print(a ...any) (n int, err error) { + return p.Fprint(p.writer, a...) +} + +// Printf mirrors [fmt.Printf] signature and behavior, with the configured style +// and (optional) word wrapping applied. +// +// Instead of writing to [os.Stdout] it will write to the configured [io.Writer]. +func (p Printer) Printf(format string, a ...any) (n int, err error) { + return p.Fprintf(p.writer, format, a...) +} + +// Printfln behaves like [fmt.Printf] but supports the [formatter] signature. +// +// This does *not* map to a Go native printer, but a mix for formatting + newline +func (p Printer) Printfln(format string, a ...any) (n int, err error) { + return p.Fprintfln(p.writer, format, a...) +} + +// Println mirrors [fmt.Println] signature and behavior, with the configured style +// and (optional) word wrapping applied. +// +// Instead of writing to [os.Stdout] it will write to the configured [io.Writer] +func (p Printer) Println(a ...any) (n int, err error) { + return p.Fprintln(p.writer, a...) +} + +// ----------------------------------------------------- +// Return string +// ----------------------------------------------------- + +// Sprint mirrors [fmt.Sprint] signature and behavior, with the configured style +// and (optional) word wrapping applied. +func (p Printer) Sprint(a ...any) string { + return p.render(fmt.Sprint(a...)) +} + +// Sprintf mirrors [fmt.Sprintf] signature and behavior, with the configured style +// and (optional) word wrapping applied. +func (p Printer) Sprintf(format string, a ...any) string { + return p.render(fmt.Sprintf(format, a...)) +} + +// Sprintfln behaves like [fmt.Sprintln] but supports the [formatter] signature. +// +// This does *not* map to a Go native printer, but a mix for formatting + newline +func (p Printer) Sprintfln(format string, a ...any) string { + return fmt.Sprintln(p.Sprintf(format, a...)) +} + +// Sprintln mirrors [fmt.Sprintln] signature and behavior, with the configured style +// and (optional) word wrapping applied. +func (p Printer) Sprintln(a ...any) string { + return fmt.Sprintln(p.printHelper(a...)) +} + +// Create a visual box with the printer style +func (p Printer) Box(header string, bodies ...string) { + body := strings.Join(bodies, " ") + + // Copy the box styles to avoid leaking changes to the styles + headerStyle, bodyStyle := p.boxHeaderStyle.Copy(), p.boxBodyStyle.Copy() + + // If there are no body, just render the header box directly + if len(body) == 0 { + fmt.Fprintln( + p.writer, + headerStyle. + Width(p.boxWidth-borderWidth). + Border(headerOnlyBorder). + Render(header), + ) + + return + } + + // Render the header and body box + boxHeader := headerStyle.Width(p.boxWidth - borderWidth).Render(header) + boxBody := bodyStyle.Width(p.boxWidth - borderWidth).Render(body) + + // If a BoxWidth is set, the boxes will be aligned automatically to the max + if p.boxWidth > 0 { + fmt.Fprintln( + p.writer, + lipgloss.JoinVertical( + lipgloss.Left, + boxHeader, + boxBody, + ), + ) + + return + } + + // Compute the width of the header and body elements + headerWidth := lipgloss.Width(boxHeader) - borderWidth + bodyWidth := lipgloss.Width(boxBody) - borderWidth + + // Find the shortest box and (re)render it to the length of the longest one + switch { + case headerWidth > bodyWidth: + boxBody = bodyStyle.Width(headerWidth).Render(body) + + case headerWidth < bodyWidth: + boxHeader = headerStyle.Width(bodyWidth).Render(header) + } + + fmt.Fprintln( + p.writer, + lipgloss.JoinVertical(lipgloss.Left, boxHeader, boxBody), + ) +} + +// ----------------------------------------------------- +// io.Writer +// ----------------------------------------------------- + +func (p Printer) Write(b []byte) (n int, err error) { + return p.Print(string(b)) +} + +// ----------------------------------------------------- +// Helper methods +// ----------------------------------------------------- + +// GetBoxWidth returns the configured [BoxWidth] for word wrapping +func (p Printer) BoxWidth() int { + return p.boxWidth +} + +// Writer returns the configured [io.Writer] +func (p Printer) Writer() io.Writer { + return p.writer +} + +func (p Printer) Copy(options ...PrinterOption) Printer { + clone := &p + + for _, option := range options { + option(clone) + } + + return *clone +} + +// TextStyle returns a *copy* of the current [lipgloss.Style] +func (p Printer) Style() lipgloss.Style { + return p.textStyle.Copy() +} + +// ApplyTextStyle returns a new copy of [StylePrint] instance with the [Style] based on the callback changes +func (p Printer) ApplyStyle(callback StyleChanger) Printer { + style := p.Style() + callback(&style) + + return p.Copy(WithTextStyle(style)) +} + +func (p Printer) GetWriter() io.Writer { + return p.writer +} + +// ----------------------------------------------------- +// internal helpers +// ----------------------------------------------------- + +func (p Printer) render(input string) string { + return p.wrap(p.textStyle.Render(input)) +} + +func (p Printer) wrap(input string) string { + return input +} + +func (p Printer) printHelper(a ...any) string { + var buff bytes.Buffer + + fmt.Fprintln(&buff, a...) + + out := buff.String() + out, _ = strings.CutSuffix(out, "\n") + + return p.render(out) +} + +// ----------------------------------------------------- +// Printer options +// ----------------------------------------------------- + +func WithStyle(style Style) PrinterOption { + return func(p *Printer) { + p.style = style + p.textStyle = p.renderer.NewStyle().Inherit(style.TextStyle()) + } +} + +func WithRenderer(renderer *lipgloss.Renderer) PrinterOption { + return func(p *Printer) { + p.renderer = renderer + p.writer = renderer.Output() + } +} + +func WithTextStyle(style lipgloss.Style) PrinterOption { + return func(p *Printer) { + p.textStyle = style + } +} + +func WithEmphasis(b bool) PrinterOption { + return func(printer *Printer) { + if b { + printer.textStyle = printer.renderer.NewStyle().Inherit(printer.style.TextEmphasisStyle()) + + return + } + + printer.textStyle = printer.renderer.NewStyle().Inherit(printer.style.TextStyle()) + } +} + +func WithWriter(w io.Writer) PrinterOption { + return func(p *Printer) { + p.writer = w + } +} + +func WitBoxWidth(i int) PrinterOption { + return func(p *Printer) { + p.boxWidth = i + } +} diff --git a/pkg/tui/style.go b/pkg/tui/style.go new file mode 100644 index 0000000..f9105e8 --- /dev/null +++ b/pkg/tui/style.go @@ -0,0 +1,100 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +type styleIdentifier int + +const ( + Danger styleIdentifier = 1 << iota + Dark + Info + Light + NoColor + Primary + Secondary + Success + Warning +) + +type Style struct { + textColor lipgloss.AdaptiveColor + textStyle lipgloss.Style + textEmphasisColor lipgloss.AdaptiveColor + textEmphasisStyle lipgloss.Style + backgroundColor lipgloss.AdaptiveColor + borderColor lipgloss.AdaptiveColor +} + +func NewStyle(baseColor lipgloss.Color) Style { + base := ColorToHex(baseColor) + + style := Style{ + textColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "", 0), + Dark: TransformColor(base, "tint", 0.4), + }, + textEmphasisColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "shade", 0.6), + Dark: TransformColor(base, "tint", 0.4), + }, + backgroundColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "tint", 0.8), + Dark: TransformColor(base, "shade", 0.8), + }, + borderColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "tint", 0.6), + Dark: TransformColor(base, "shade", 0.4), + }, + } + + style.textStyle = lipgloss. + NewStyle(). + Foreground(style.textColor) + + style.textEmphasisStyle = lipgloss. + NewStyle(). + Bold(true). + Foreground(style.textEmphasisColor). + Background(style.backgroundColor). + BorderForeground(style.borderColor) + + return style +} + +func NewStyleWithoutColor() Style { + // Since all lipgloss.Styles are non-pointers, they are by default an empty / unstyled version of themselves + return Style{} +} + +func (style Style) NewPrinter(renderer *lipgloss.Renderer, options ...PrinterOption) Printer { + return NewPrinter(style, renderer, options...) +} + +func (style Style) TextStyle() lipgloss.Style { + return style.textStyle +} + +func (style Style) TextEmphasisStyle() lipgloss.Style { + return style.textEmphasisStyle +} + +func (style Style) BoxHeader() lipgloss.Style { + return lipgloss.NewStyle(). + Align(lipgloss.Center, lipgloss.Center). + Border(headerBorder). + BorderForeground(style.borderColor). + PaddingBottom(1). + PaddingTop(1). + Inherit(style.TextEmphasisStyle()) +} + +func (style Style) BoxBody() lipgloss.Style { + return lipgloss.NewStyle(). + Align(lipgloss.Left). + Border(bodyBorder). + BorderForeground(style.borderColor). + BorderTop(false). + Padding(1) +} diff --git a/pkg/tui/theme.go b/pkg/tui/theme.go new file mode 100644 index 0000000..edb2f70 --- /dev/null +++ b/pkg/tui/theme.go @@ -0,0 +1,63 @@ +package tui + +import ( + "context" + "io" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +type Theme struct { + styles map[styleIdentifier]Style +} + +func NewTheme() Theme { + theme := Theme{} + theme.styles = make(map[styleIdentifier]Style) + theme.styles[Danger] = NewStyle(Red) + theme.styles[Info] = NewStyle(Cyan) + theme.styles[Light] = NewStyle(Gray300) + theme.styles[NoColor] = NewStyleWithoutColor() + theme.styles[Primary] = NewStyle(Blue) + theme.styles[Secondary] = NewStyle(Gray600) + theme.styles[Success] = NewStyle(Green) + theme.styles[Warning] = NewStyle(Yellow) + + dark := NewStyle(Gray700) + dark.textEmphasisColor.Dark = ColorToHex(Gray300) + dark.backgroundColor.Dark = "#1a1d20" + dark.borderColor.Dark = ColorToHex(Gray800) + + theme.styles[Dark] = dark + + return theme +} + +func (theme Theme) Style(id styleIdentifier) Style { + return theme.styles[id] +} + +func (theme Theme) Writer(renderer *lipgloss.Renderer) Writer { + return Writer{ + renderer: renderer, + theme: theme, + cache: make(map[styleIdentifier]Printer), + } +} + +func NewWriter(ctx context.Context, writer io.Writer) Writer { + var options []termenv.OutputOption + + // If the primary (stdout) color profile is in color mode (aka not ASCII), + // force TTY and color profile for the new renderer and writer + if profile := ColorProfileFromContext(ctx); profile != termenv.Ascii { + options = append( + options, + termenv.WithTTY(true), + termenv.WithProfile(profile), + ) + } + + return ThemeFromContext(ctx).Writer(lipgloss.NewRenderer(writer, options...)) +} diff --git a/pkg/tui/writer.go b/pkg/tui/writer.go new file mode 100644 index 0000000..e44a801 --- /dev/null +++ b/pkg/tui/writer.go @@ -0,0 +1,57 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +type Writer struct { + cache map[styleIdentifier]Printer + theme Theme + renderer *lipgloss.Renderer +} + +func (w Writer) Danger() Printer { + return w.Style(Danger) +} + +func (w Writer) Dark() Printer { + return w.Style(Dark) +} + +func (w Writer) Info() Printer { + return w.Style(Info) +} + +func (w Writer) Light() Printer { + return w.Style(Light) +} + +func (w Writer) NoColor() Printer { + return w.Style(NoColor) +} + +func (w Writer) Primary() Printer { + return w.Style(Primary) +} + +func (w Writer) Secondary() Printer { + return w.Style(Secondary) +} + +func (w Writer) Success() Printer { + return w.Style(Success) +} + +func (w Writer) Warning() Printer { + return w.Style(Warning) +} + +func (w Writer) Style(colorType styleIdentifier) Printer { + if printer, ok := w.cache[colorType]; ok { + return printer + } + + w.cache[colorType] = w.theme.Style(colorType).NewPrinter(w.renderer) + + return w.cache[colorType] +} diff --git a/schema/gitlab.schema.graphqls b/schema/gitlab.schema.graphqls index f087a14..b2c75da 100644 --- a/schema/gitlab.schema.graphqls +++ b/schema/gitlab.schema.graphqls @@ -21,6 +21,8 @@ scalar Time # Add time.Duration support scalar Duration +# Add 'any' type for Event +scalar Any type Context { "The project the Merge Request belongs to" @@ -31,6 +33,9 @@ type Context { "Information about the Merge Request" MergeRequest: ContextMergeRequest @generated + + "Information about the event that triggered the evaluation. Empty when not using webhook server." + WebhookEvent: Any @generated } enum MergeRequestState {