From b38fa579c7724f9bec08b95c76df29069e389464 Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 00:03:04 +0200
Subject: [PATCH 01/10] feat: add server mode

---
 cmd/cmd_server.go                      | 104 ++++++++++++++++++++-----
 main.go                                |  19 ++---
 pkg/scm/gitlab/client_merge_request.go |  16 ++++
 pkg/scm/interfaces.go                  |   2 +
 4 files changed, 114 insertions(+), 27 deletions(-)

diff --git a/cmd/cmd_server.go b/cmd/cmd_server.go
index 19ec162..1177dca 100644
--- a/cmd/cmd_server.go
+++ b/cmd/cmd_server.go
@@ -2,53 +2,121 @@ package cmd
 
 import (
 	"encoding/json"
+	"errors"
+	"fmt"
 	"log"
 	"log/slog"
 	"net/http"
+	"strconv"
 
-	"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"
 )
 
-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 {
+	ID                int    `json:"id"`
+	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(w http.ResponseWriter, code int, err error) {
+	slog.Error(err.Error())
+
+	w.WriteHeader(code)
+	w.Write([]byte(err.Error()))
+
+	return
+}
+
+func Server(cCtx *cli.Context) error { //nolint:unparam
 	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")
+	// Initialize GitLab client
+	client, err := gitlab.NewClient(cCtx.String(FlagAPIToken), cCtx.String(FlagSCMBaseURL))
+	if err != nil {
+		return err
+	}
 
-			writer.WriteHeader(http.StatusInternalServerError)
+	mux.HandleFunc("POST /gitlab", func(writer http.ResponseWriter, reader *http.Request) {
+		// Validate headers
+		if reader.Header.Get("Content-Type") != "application/json" {
+			errHandler(writer, http.StatusInternalServerError, errors.New("not json"))
 
 			return
 		}
 
-		var evt gitlab.MergeRequestEventPayload
-		if err := json.NewDecoder(reader.Body).Decode(&evt); err != nil {
-			writer.WriteHeader(http.StatusInternalServerError)
+		// Decode request payload
+		var payload Payload
+		if err := json.NewDecoder(reader.Body).Decode(&payload); err != nil {
+			errHandler(writer, http.StatusInternalServerError, err)
 
 			return
 		}
 
-		writer.WriteHeader(http.StatusOK)
-	})
+		// Initialize context
+		ctx := state.ContextWithProjectID(reader.Context(), payload.Project.PathWithNamespace)
 
-	mux.HandleFunc("POST /push", func(writer http.ResponseWriter, reader *http.Request) {
-		if reader.Header.Get("Content-Type") != "application/json" {
-			slog.Warn("not json")
+		// 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(writer, http.StatusInternalServerError, fmt.Errorf("unknown event: %s", payload.EventType))
+		}
+
+		// Get the remote config file
+		file, err := client.MergeRequests().GetRemoteConfig(ctx, cCtx.String(FlagConfigFile), ref)
+		if err != nil {
+			errHandler(writer, http.StatusOK, err)
+
+			return
+		}
 
-			writer.WriteHeader(http.StatusInternalServerError)
+		// Parse the file
+		cfg, err := config.ParseFile(file)
+		if err != nil {
+			errHandler(writer, http.StatusOK, err)
 
 			return
 		}
 
-		var evt gitlab.PushEventPayload
-		if err := json.NewDecoder(reader.Body).Decode(&evt); err != nil {
-			writer.WriteHeader(http.StatusInternalServerError)
+		// Process the MR
+		if err := ProcessMR(ctx, client, cfg, id); err != nil {
+			errHandler(writer, http.StatusOK, err)
 
 			return
 		}
 
 		writer.WriteHeader(http.StatusOK)
+		writer.Write([]byte("OK"))
 	})
 
 	log.Fatal(http.ListenAndServe("0.0.0.0:3000", mux)) //nolint:gosec
diff --git a/main.go b/main.go
index d308ac9..3775625 100644
--- a/main.go
+++ b/main.go
@@ -43,15 +43,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 +60,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",
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/interfaces.go b/pkg/scm/interfaces.go
index e6801b9..3462cc9 100644
--- a/pkg/scm/interfaces.go
+++ b/pkg/scm/interfaces.go
@@ -2,6 +2,7 @@ package scm
 
 import (
 	"context"
+	"io"
 )
 
 type Client interface {
@@ -20,6 +21,7 @@ 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 {

From cbdb1a9af4b449fdea098707ff2a96ca43b2486f Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 00:14:07 +0200
Subject: [PATCH 02/10] fix: remove unneeded struct field from server payload

---
 cmd/cmd_server.go | 1 -
 1 file changed, 1 deletion(-)

diff --git a/cmd/cmd_server.go b/cmd/cmd_server.go
index 1177dca..830e8c9 100644
--- a/cmd/cmd_server.go
+++ b/cmd/cmd_server.go
@@ -25,7 +25,6 @@ type MergeRequest struct {
 }
 
 type Project struct {
-	ID                int    `json:"id"`
 	PathWithNamespace string `json:"path_with_namespace"`
 }
 

From 4d1757eee7759494450022ac27a0472747b51841 Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 01:07:48 +0200
Subject: [PATCH 03/10] feat: add support for gitlab webhook secrets

---
 cmd/cmd_server.go  | 11 +++++++++++
 cmd/conventions.go |  1 +
 main.go            |  9 +++++++++
 3 files changed, 21 insertions(+)

diff --git a/cmd/cmd_server.go b/cmd/cmd_server.go
index 830e8c9..d62daf6 100644
--- a/cmd/cmd_server.go
+++ b/cmd/cmd_server.go
@@ -47,6 +47,8 @@ func errHandler(w http.ResponseWriter, code int, err error) {
 func Server(cCtx *cli.Context) error { //nolint:unparam
 	mux := http.NewServeMux()
 
+	ourSecret := cCtx.String(FlagWebhookSecret)
+
 	// Initialize GitLab client
 	client, err := gitlab.NewClient(cCtx.String(FlagAPIToken), cCtx.String(FlagSCMBaseURL))
 	if err != nil {
@@ -54,6 +56,15 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 	}
 
 	mux.HandleFunc("POST /gitlab", func(writer http.ResponseWriter, reader *http.Request) {
+		if len(ourSecret) > 0 {
+			theirSecret := reader.Header.Get("X-Gitlab-Token")
+			if ourSecret != theirSecret {
+				errHandler(writer, http.StatusForbidden, errors.New("Missing or invalid X-Gitlab-Token header"))
+
+				return
+			}
+		}
+
 		// Validate headers
 		if reader.Header.Get("Content-Type") != "application/json" {
 			errHandler(writer, http.StatusInternalServerError, errors.New("not json"))
diff --git a/cmd/conventions.go b/cmd/conventions.go
index 7d75c25..c9ed71b 100644
--- a/cmd/conventions.go
+++ b/cmd/conventions.go
@@ -6,4 +6,5 @@ const (
 	FlagSCMProject     = "project"
 	FlagSCMBaseURL     = "base-url"
 	FlagMergeRequestID = "id"
+	FlagWebhookSecret  = "webhook-secret"
 )
diff --git a/main.go b/main.go
index 3775625..7a8e18f 100644
--- a/main.go
+++ b/main.go
@@ -86,6 +86,15 @@ 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",
+						},
+					},
+				},
 			},
 		},
 	}

From 63b08944fd605ade55948cb3f980f2bb1273ade8 Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 01:30:28 +0200
Subject: [PATCH 04/10] feat: expose full webhook payload in expr as
 'webhook_event'

---
 cmd/cmd_evaluate.go           |  6 +++---
 cmd/cmd_server.go             | 21 +++++++++++++++++++--
 cmd/shared.go                 |  4 +++-
 pkg/scm/gitlab/context.go     |  4 ++++
 pkg/scm/interfaces.go         |  1 +
 schema/gitlab.schema.graphqls |  5 +++++
 6 files changed, 35 insertions(+), 6 deletions(-)

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 d62daf6..33cb96b 100644
--- a/cmd/cmd_server.go
+++ b/cmd/cmd_server.go
@@ -1,9 +1,11 @@
 package cmd
 
 import (
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"log"
 	"log/slog"
 	"net/http"
@@ -72,9 +74,16 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 			return
 		}
 
+		body, err := io.ReadAll(reader.Body)
+		if err != nil {
+			errHandler(writer, http.StatusInternalServerError, err)
+
+			return
+		}
+
 		// Decode request payload
 		var payload Payload
-		if err := json.NewDecoder(reader.Body).Decode(&payload); err != nil {
+		if err := json.NewDecoder(bytes.NewReader(body)).Decode(&payload); err != nil {
 			errHandler(writer, http.StatusInternalServerError, err)
 
 			return
@@ -118,8 +127,16 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 			return
 		}
 
+		// Decode request payload
+		var full any
+		if err := json.NewDecoder(bytes.NewReader(body)).Decode(&full); err != nil {
+			errHandler(writer, http.StatusInternalServerError, err)
+
+			return
+		}
+
 		// Process the MR
-		if err := ProcessMR(ctx, client, cfg, id); err != nil {
+		if err := ProcessMR(ctx, client, cfg, id, full); err != nil {
 			errHandler(writer, http.StatusOK, err)
 
 			return
diff --git a/cmd/shared.go b/cmd/shared.go
index 87d325a..5ea795d 100644
--- a/cmd/shared.go
+++ b/cmd/shared.go
@@ -10,7 +10,7 @@ import (
 	"github.com/jippi/scm-engine/pkg/state"
 )
 
-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++ {
@@ -34,6 +34,8 @@ func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, mr st
 		return nil
 	}
 
+	evalContext.SetWebhookEvent(event)
+
 	fmt.Println("Evaluating context")
 
 	labels, actions, err := cfg.Evaluate(evalContext)
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 3462cc9..7ba9140 100644
--- a/pkg/scm/interfaces.go
+++ b/pkg/scm/interfaces.go
@@ -26,6 +26,7 @@ type MergeRequestClient interface {
 
 type EvalContext interface {
 	IsValid() bool
+	SetWebhookEvent(in any)
 }
 
 type EvalContextualizer struct{}
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 {

From c90aa23ec7a9b9264e8a7359f053e52fe176d8c5 Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 01:31:04 +0200
Subject: [PATCH 05/10] build: go mod tidy

---
 go.mod | 1 -
 go.sum | 7 -------
 2 files changed, 8 deletions(-)

diff --git a/go.mod b/go.mod
index e6c80e2..fb56030 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,6 @@ require (
 	github.com/davecgh/go-spew v1.1.1
 	github.com/expr-lang/expr v1.16.6
 	github.com/fatih/structtag v1.2.0
-	github.com/go-playground/webhooks/v6 v6.3.0
 	github.com/guregu/null/v5 v5.0.0
 	github.com/hasura/go-graphql-client v0.12.1
 	github.com/iancoleman/strcase v0.3.0
diff --git a/go.sum b/go.sum
index e6a77a6..c93ef19 100644
--- a/go.sum
+++ b/go.sum
@@ -8,7 +8,6 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig
 github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
 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=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
@@ -17,9 +16,6 @@ github.com/expr-lang/expr v1.16.6 h1:u1mrPXbwHtWAih5ZP24ZDG+ht8CB5xB0aBvagkAWPY0
 github.com/expr-lang/expr v1.16.6/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/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=
@@ -53,9 +49,7 @@ 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=
 github.com/sosodev/duration v1.3.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-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/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
@@ -85,7 +79,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 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=
 nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0=

From 233e5ecd282a97b405beaa04307aa3d71bfdd140 Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 13:52:57 +0200
Subject: [PATCH 06/10] feat: add tui package and reorganize log output

---
 cmd/cmd_server.go              |  21 +-
 cmd/shared.go                  |  35 +-
 go.mod                         |  20 +
 go.sum                         |  54 +++
 main.go                        |   6 +
 pkg/colors/colors.go           | 134 -------
 pkg/config/label.go            |   6 +-
 pkg/scm/gitlab/client_label.go |   4 +-
 pkg/state/context.go           |  18 +-
 pkg/tui/colors.go              | 706 +++++++++++++++++++++++++++++++++
 pkg/tui/context.go             |  94 +++++
 pkg/tui/conventions.go         |  40 ++
 pkg/tui/helpers.go             |  42 ++
 pkg/tui/logger.go              |  91 +++++
 pkg/tui/printer.go             | 319 +++++++++++++++
 pkg/tui/style.go               | 100 +++++
 pkg/tui/theme.go               |  63 +++
 pkg/tui/writer.go              |  57 +++
 18 files changed, 1642 insertions(+), 168 deletions(-)
 delete mode 100644 pkg/colors/colors.go
 create mode 100644 pkg/tui/colors.go
 create mode 100644 pkg/tui/context.go
 create mode 100644 pkg/tui/conventions.go
 create mode 100644 pkg/tui/helpers.go
 create mode 100644 pkg/tui/logger.go
 create mode 100644 pkg/tui/printer.go
 create mode 100644 pkg/tui/style.go
 create mode 100644 pkg/tui/theme.go
 create mode 100644 pkg/tui/writer.go

diff --git a/cmd/cmd_server.go b/cmd/cmd_server.go
index 33cb96b..621ac38 100644
--- a/cmd/cmd_server.go
+++ b/cmd/cmd_server.go
@@ -2,19 +2,22 @@ package cmd
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
-	"log"
 	"log/slog"
+	"net"
 	"net/http"
 	"strconv"
+	"time"
 
 	"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"
 )
 
 type Commit struct {
@@ -47,6 +50,8 @@ func errHandler(w http.ResponseWriter, code int, err error) {
 }
 
 func Server(cCtx *cli.Context) error { //nolint:unparam
+	slogctx.Info(cCtx.Context, "Starting HTTP server")
+
 	mux := http.NewServeMux()
 
 	ourSecret := cCtx.String(FlagWebhookSecret)
@@ -58,6 +63,8 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 	}
 
 	mux.HandleFunc("POST /gitlab", func(writer http.ResponseWriter, reader *http.Request) {
+		slogctx.Info(reader.Context(), "Handling /gitlab request")
+
 		if len(ourSecret) > 0 {
 			theirSecret := reader.Header.Get("X-Gitlab-Token")
 			if ourSecret != theirSecret {
@@ -146,7 +153,15 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 		writer.Write([]byte("OK"))
 	})
 
-	log.Fatal(http.ListenAndServe("0.0.0.0:3000", mux)) //nolint:gosec
+	server := &http.Server{
+		Addr:         "0.0.0.0:3000",
+		Handler:      http.Handler(mux),
+		ReadTimeout:  5 * time.Second,
+		WriteTimeout: 5 * time.Second,
+		BaseContext: func(l net.Listener) context.Context {
+			return cCtx.Context
+		},
+	}
 
-	return nil
+	return server.ListenAndServe()
 }
diff --git a/cmd/shared.go b/cmd/shared.go
index 5ea795d..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, 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,28 +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
 	}
 
 	evalContext.SetWebhookEvent(event)
 
-	fmt.Println("Evaluating context")
+	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
@@ -69,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
 }
 
@@ -107,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 {
@@ -120,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
@@ -131,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
@@ -153,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
@@ -164,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 fd8d04d..0fa291d 100644
--- a/go.mod
+++ b/go.mod
@@ -4,14 +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/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
@@ -20,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 2cf844f..e519047 100644
--- a/go.sum
+++ b/go.sum
@@ -6,8 +6,13 @@ 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=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
@@ -16,6 +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-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=
@@ -39,35 +48,73 @@ 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=
 github.com/sosodev/duration v1.3.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+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=
@@ -79,7 +126,14 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 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 7a8e18f..d69a8f4 100644
--- a/main.go
+++ b/main.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/davecgh/go-spew/spew"
 	"github.com/jippi/scm-engine/cmd"
+	"github.com/jippi/scm-engine/pkg/tui"
 	"github.com/urfave/cli/v2"
 )
 
@@ -25,6 +26,11 @@ func main() {
 				Email: "gitlab-engine@jippi.dev",
 			},
 		},
+		Before: func(cCtx *cli.Context) error {
+			cCtx.Context = tui.NewContext(cCtx.Context, cCtx.App.Writer, cCtx.App.ErrWriter)
+
+			return nil
+		},
 		Flags: []cli.Flag{
 			&cli.StringFlag{
 				Name:      cmd.FlagConfigFile,
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/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]
+}

From c6f7f1251dc0627da46234bca8ffc46863027327 Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 14:21:23 +0200
Subject: [PATCH 07/10] feat: configure what ip/port server should listen to

---
 .golangci.yaml     |  2 ++
 cmd/cmd_server.go  | 69 +++++++++++++++++++++++++++++-----------------
 cmd/conventions.go |  1 +
 main.go            |  8 ++++++
 4 files changed, 54 insertions(+), 26 deletions(-)

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/cmd/cmd_server.go b/cmd/cmd_server.go
index 621ac38..6ea86c0 100644
--- a/cmd/cmd_server.go
+++ b/cmd/cmd_server.go
@@ -40,8 +40,14 @@ type Payload struct {
 	MergeRequest     *MergeRequest `json:"merge_request,omitempty"`     // "merge_request" is sent on "note" activity
 }
 
-func errHandler(w http.ResponseWriter, code int, err error) {
-	slog.Error(err.Error())
+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()))
@@ -50,7 +56,7 @@ func errHandler(w http.ResponseWriter, code int, err error) {
 }
 
 func Server(cCtx *cli.Context) error { //nolint:unparam
-	slogctx.Info(cCtx.Context, "Starting HTTP server")
+	slogctx.Info(cCtx.Context, "Starting HTTP server", slog.String("listen", cCtx.String(FlagServerListen)))
 
 	mux := http.NewServeMux()
 
@@ -62,42 +68,51 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 		return err
 	}
 
-	mux.HandleFunc("POST /gitlab", func(writer http.ResponseWriter, reader *http.Request) {
-		slogctx.Info(reader.Context(), "Handling /gitlab request")
+	mux.HandleFunc("POST /gitlab", func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
 
+		slogctx.Info(ctx, "Handling /gitlab request")
+
+		// Check if the webhook secret is set (and if its matching)
 		if len(ourSecret) > 0 {
-			theirSecret := reader.Header.Get("X-Gitlab-Token")
+			theirSecret := r.Header.Get("X-Gitlab-Token")
 			if ourSecret != theirSecret {
-				errHandler(writer, http.StatusForbidden, errors.New("Missing or invalid X-Gitlab-Token header"))
+				errHandler(ctx, w, http.StatusForbidden, errors.New("Missing or invalid X-Gitlab-Token header"))
 
 				return
 			}
 		}
 
-		// Validate headers
-		if reader.Header.Get("Content-Type") != "application/json" {
-			errHandler(writer, http.StatusInternalServerError, errors.New("not json"))
+		// 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
 		}
 
-		body, err := io.ReadAll(reader.Body)
+		// Read the POST body of the request
+		body, err := io.ReadAll(r.Body)
 		if err != nil {
-			errHandler(writer, http.StatusInternalServerError, err)
+			errHandler(ctx, w, http.StatusBadRequest, err)
 
 			return
 		}
 
+		// 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(writer, http.StatusInternalServerError, err)
+			errHandler(ctx, w, http.StatusBadRequest, fmt.Errorf("could not decode POST body into Payload struct: %w", err))
 
 			return
 		}
 
 		// Initialize context
-		ctx := state.ContextWithProjectID(reader.Context(), payload.Project.PathWithNamespace)
+		ctx = state.ContextWithProjectID(ctx, payload.Project.PathWithNamespace)
 
 		// Grab event specific information
 		var (
@@ -115,13 +130,15 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 			ref = payload.MergeRequest.LastCommit.ID
 
 		default:
-			errHandler(writer, http.StatusInternalServerError, fmt.Errorf("unknown event: %s", payload.EventType))
+			errHandler(ctx, w, http.StatusInternalServerError, fmt.Errorf("unknown event type: %s", payload.EventType))
 		}
 
+		ctx = slogctx.With(ctx, slog.String("event_type", payload.EventType), slog.String("merge_request_id", id), slog.String("sha_reference", ref))
+
 		// Get the remote config file
 		file, err := client.MergeRequests().GetRemoteConfig(ctx, cCtx.String(FlagConfigFile), ref)
 		if err != nil {
-			errHandler(writer, http.StatusOK, err)
+			errHandler(ctx, w, http.StatusOK, fmt.Errorf("could not read remote config file: %w", err))
 
 			return
 		}
@@ -129,32 +146,32 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
 		// Parse the file
 		cfg, err := config.ParseFile(file)
 		if err != nil {
-			errHandler(writer, http.StatusOK, err)
+			errHandler(ctx, w, http.StatusOK, fmt.Errorf("could not parse config file: %w", err))
 
 			return
 		}
 
-		// Decode request payload
-		var full any
-		if err := json.NewDecoder(bytes.NewReader(body)).Decode(&full); err != nil {
-			errHandler(writer, http.StatusInternalServerError, err)
+		// 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
 		}
 
 		// Process the MR
-		if err := ProcessMR(ctx, client, cfg, id, full); err != nil {
-			errHandler(writer, http.StatusOK, err)
+		if err := ProcessMR(ctx, client, cfg, id, fullEventPayload); err != nil {
+			errHandler(ctx, w, http.StatusOK, err)
 
 			return
 		}
 
-		writer.WriteHeader(http.StatusOK)
-		writer.Write([]byte("OK"))
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte("OK"))
 	})
 
 	server := &http.Server{
-		Addr:         "0.0.0.0:3000",
+		Addr:         cCtx.String(FlagServerListen),
 		Handler:      http.Handler(mux),
 		ReadTimeout:  5 * time.Second,
 		WriteTimeout: 5 * time.Second,
diff --git a/cmd/conventions.go b/cmd/conventions.go
index c9ed71b..9a33180 100644
--- a/cmd/conventions.go
+++ b/cmd/conventions.go
@@ -7,4 +7,5 @@ const (
 	FlagSCMBaseURL     = "base-url"
 	FlagMergeRequestID = "id"
 	FlagWebhookSecret  = "webhook-secret"
+	FlagServerListen   = "listen-port"
 )
diff --git a/main.go b/main.go
index d69a8f4..b068961 100644
--- a/main.go
+++ b/main.go
@@ -100,6 +100,14 @@ func main() {
 							"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",
+						},
+					},
 				},
 			},
 		},

From 4ec6be162671065055ac8fd077c8083cd324929e Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 14:29:47 +0200
Subject: [PATCH 08/10] build: include version in build artefacts

---
 .goreleaser.yaml |  2 +-
 main.go          | 14 +++++++++++++-
 2 files changed, 14 insertions(+), 2 deletions(-)

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/main.go b/main.go
index b068961..610a1b5 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"io"
 	"log"
 	"os"
@@ -9,6 +10,15 @@ import (
 	"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() {
@@ -20,14 +30,16 @@ 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
 		},

From 2892112298c0555d449760dbc3380982c824e234 Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 14:32:28 +0200
Subject: [PATCH 09/10] ci: omit -h flag in build test

---
 .github/workflows/build.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2891a71..c1d4ef3 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -100,7 +100,7 @@ jobs:
           token: ${{ secrets.CODECOV_TOKEN }}
 
       - name: Ensure scm-engine binary work
-        run: ./scm-engine -h
+        run: ./scm-engine
 
       - name: Test scm-engine against test GitLab project
         run: ./scm-engine evaluate 1

From 532eb2cb653d6034d5ef339451739de4d8556bea Mon Sep 17 00:00:00 2001
From: Christian Winther <jippignu@gmail.com>
Date: Thu, 9 May 2024 14:39:44 +0200
Subject: [PATCH 10/10] ci: full clone

---
 .github/workflows/build.yml    | 114 ---------------------------------
 .github/workflows/codeql.yml   |   3 +
 .github/workflows/gitleaks.yml |   7 +-
 .github/workflows/grype.yml    |   8 ++-
 .github/workflows/lint.yml     |   2 +
 .github/workflows/release.yml  |   2 -
 .github/workflows/security.yml |  63 ++++++++++++++++++
 .github/workflows/test.yml     |  59 +++++++++++++++++
 Taskfile.yml                   |   4 +-
 9 files changed, 138 insertions(+), 124 deletions(-)
 delete mode 100644 .github/workflows/build.yml
 create mode 100644 .github/workflows/security.yml
 create mode 100644 .github/workflows/test.yml

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
deleted file mode 100644
index c1d4ef3..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
-
-      - 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/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