diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 737bbfd..2c562e2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -46,11 +46,11 @@ jobs:
- name: Ensure scm-engine binary work
run: ./scm-engine -h
- - name: Test scm-engine against test GitLab project
+ - name: Test scm-engine against a GitLab project
run: ./scm-engine evaluate all
env:
SCM_ENGINE_TOKEN: "${{ secrets.GITLAB_INTEGRATION_TEST_API_TOKEN }}"
- SCM_ENGINE_CONFIG_FILE: ".scm-engine.example.yml"
+ SCM_ENGINE_CONFIG_FILE: ".scm-engine.gitlab.example.yml"
GITLAB_PROJECT: "jippi/scm-engine-schema-test"
GITLAB_BASEURL: https://gitlab.com/
diff --git a/.gitignore b/.gitignore
index 9bb60dd..bfcc38e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@
/scm-engine
/scm-engine.exe
/docs/gitlab/script-attributes.md
+/docs/github/script-attributes.md
diff --git a/.scm-engine.example.yml b/.scm-engine.gitlab.example.yml
similarity index 100%
rename from .scm-engine.example.yml
rename to .scm-engine.gitlab.example.yml
diff --git a/Taskfile.yml b/Taskfile.yml
index 2f1058c..f1785ff 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -14,6 +14,9 @@ tasks:
setup:
desc: Install dependencies
+ vars:
+ # don't use the automatic provider detection in CI
+ SCM_ENGINE_DONT_DETECT_PROVIDER: 1
cmds:
- go mod tidy
- go generate ./...
diff --git a/cmd/cmd_evaluate.go b/cmd/cmd_evaluate.go
index dbd6136..c15c301 100644
--- a/cmd/cmd_evaluate.go
+++ b/cmd/cmd_evaluate.go
@@ -5,15 +5,18 @@ import (
"github.com/jippi/scm-engine/pkg/config"
"github.com/jippi/scm-engine/pkg/scm"
- "github.com/jippi/scm-engine/pkg/scm/gitlab"
"github.com/jippi/scm-engine/pkg/state"
"github.com/urfave/cli/v2"
)
func Evaluate(cCtx *cli.Context) error {
ctx := state.WithProjectID(cCtx.Context, cCtx.String(FlagSCMProject))
+ ctx = state.WithBaseURL(ctx, cCtx.String(FlagSCMBaseURL))
ctx = state.WithCommitSHA(ctx, cCtx.String(FlagCommitSHA))
ctx = state.WithDryRun(ctx, cCtx.Bool(FlagDryRun))
+ ctx = state.WithProvider(ctx, cCtx.String(FlagProvider))
+ ctx = state.WithToken(ctx, cCtx.String(FlagAPIToken))
+ ctx = state.WithToken(ctx, cCtx.String(FlagAPIToken))
ctx = state.WithUpdatePipeline(ctx, cCtx.Bool(FlagUpdatePipeline))
cfg, err := config.LoadFile(cCtx.String(FlagConfigFile))
@@ -21,7 +24,7 @@ func Evaluate(cCtx *cli.Context) error {
return err
}
- client, err := gitlab.NewClient(cCtx.String(FlagAPIToken), cCtx.String(FlagSCMBaseURL))
+ client, err := getClient(ctx)
if err != nil {
return err
}
diff --git a/cmd/cmd_server.go b/cmd/cmd_server.go
index 8846118..269a2cf 100644
--- a/cmd/cmd_server.go
+++ b/cmd/cmd_server.go
@@ -14,7 +14,6 @@ import (
"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"
@@ -55,15 +54,22 @@ func errHandler(ctx context.Context, w http.ResponseWriter, code int, err error)
return
}
-func Server(cCtx *cli.Context) error { //nolint:unparam
- slogctx.Info(cCtx.Context, "Starting HTTP server", slog.String("listen", cCtx.String(FlagServerListen)))
+func Server(cCtx *cli.Context) error {
+ // Initialize context
+ ctx := state.WithDryRun(cCtx.Context, cCtx.Bool(FlagDryRun))
+ ctx = state.WithBaseURL(ctx, cCtx.String(FlagSCMBaseURL))
+ ctx = state.WithToken(ctx, cCtx.String(FlagAPIToken))
+ ctx = state.WithProvider(ctx, cCtx.String(FlagProvider))
+ ctx = state.WithUpdatePipeline(ctx, cCtx.Bool(FlagUpdatePipeline))
+
+ slogctx.Info(ctx, "Starting HTTP server", slog.String("listen", cCtx.String(FlagServerListen)))
mux := http.NewServeMux()
ourSecret := cCtx.String(FlagWebhookSecret)
- // Initialize GitLab client
- client, err := gitlab.NewClient(cCtx.String(FlagAPIToken), cCtx.String(FlagSCMBaseURL))
+ // Initialize client
+ client, err := getClient(cCtx.Context)
if err != nil {
return err
}
@@ -179,9 +185,6 @@ func Server(cCtx *cli.Context) error { //nolint:unparam
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
BaseContext: func(l net.Listener) context.Context {
- ctx := state.WithDryRun(cCtx.Context, cCtx.Bool(FlagDryRun))
- ctx = state.WithUpdatePipeline(ctx, cCtx.Bool(FlagUpdatePipeline))
-
return ctx
},
}
diff --git a/cmd/conventions.go b/cmd/conventions.go
index 94ce71e..313543d 100644
--- a/cmd/conventions.go
+++ b/cmd/conventions.go
@@ -2,13 +2,14 @@ package cmd
const (
FlagAPIToken = "api-token"
+ FlagCommitSHA = "commit"
FlagConfigFile = "config"
FlagDryRun = "dry-run"
FlagMergeRequestID = "id"
- FlagUpdatePipeline = "update-pipeline"
- FlagCommitSHA = "commit"
+ FlagProvider = "provider"
FlagSCMBaseURL = "base-url"
FlagSCMProject = "project"
FlagServerListen = "listen"
+ FlagUpdatePipeline = "update-pipeline"
FlagWebhookSecret = "webhook-secret"
)
diff --git a/cmd/shared.go b/cmd/shared.go
index 88e1fdd..3099ffc 100644
--- a/cmd/shared.go
+++ b/cmd/shared.go
@@ -8,14 +8,29 @@ import (
"github.com/jippi/scm-engine/pkg/config"
"github.com/jippi/scm-engine/pkg/scm"
+ "github.com/jippi/scm-engine/pkg/scm/github"
+ "github.com/jippi/scm-engine/pkg/scm/gitlab"
"github.com/jippi/scm-engine/pkg/state"
slogctx "github.com/veqryn/slog-context"
)
+func getClient(ctx context.Context) (scm.Client, error) {
+ switch state.Provider(ctx) {
+ case "github":
+ return github.NewClient(ctx), nil
+
+ case "gitlab":
+ return gitlab.NewClient(ctx)
+
+ default:
+ return nil, fmt.Errorf("unknown provider %q - we only support 'github' and 'gitlab'", state.Provider(ctx))
+ }
+}
+
func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, event any) (err error) {
// Stop the pipeline when we leave this func
defer func() {
- if stopErr := client.Stop(ctx, err); err != nil {
+ if stopErr := client.Stop(ctx, err); stopErr != nil {
slogctx.Error(ctx, "Failed to update pipeline", slog.Any("error", stopErr))
}
}()
@@ -157,12 +172,12 @@ func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, req
// Update
for _, label := range required {
- e, ok := remoteLabels[label.Name]
+ remote, ok := remoteLabels[label.Name]
if !ok {
continue
}
- if label.IsEqual(e) {
+ if label.IsEqual(ctx, remote) {
continue
}
diff --git a/docs/commands/evaluate.md b/docs/commands/evaluate.md
index c787313..89fab77 100644
--- a/docs/commands/evaluate.md
+++ b/docs/commands/evaluate.md
@@ -15,16 +15,17 @@ USAGE:
scm-engine evaluate [command options] [mr_id, mr_id, ...]
OPTIONS:
- --project value GitLab project (example: 'gitlab-org/gitlab') [$GITLAB_PROJECT, $CI_PROJECT_PATH]
- --id value The pull/merge ID to process, if not provided as a CLI flag [$CI_MERGE_REQUEST_IID]
- --commit value The git commit sha [$CI_COMMIT_SHA]
--update-pipeline Update the CI pipeline status with progress (default: false) [$SCM_ENGINE_UPDATE_PIPELINE]
+ --project value GitLab project (example: 'gitlab-org/gitlab') [$GITLAB_PROJECT, $CI_PROJECT_PATH, $GITHUB_REPOSITORY]
+ --id value The pull/merge ID to process, if not provided as a CLI flag [$CI_MERGE_REQUEST_IID]
+ --commit value The git commit sha [$CI_COMMIT_SHA, $GITHUB_SHA]
--help, -h show help
GLOBAL OPTIONS:
--config value Path to the scm-engine config file (default: ".scm-engine.yml") [$SCM_ENGINE_CONFIG_FILE]
- --api-token value GitHub/GitLab API token [$SCM_ENGINE_TOKEN]
- --base-url value Base URL for the SCM instance (default: "https://gitlab.com/") [$GITLAB_BASEURL, $CI_SERVER_URL]
+ --provider value SCM provider to use. Must be either "github" or "gitlab". SCM Engine will automatically detect "github" if "GITHUB_ACTIONS" environment variable is set (e.g., inside GitHub Actions) and detect "gitlab" if "GITLAB_CI" environment variable is set (e.g., inside GitLab CI). [$SCM_ENGINE_PROVIDER]
+ --api-token value GitHub/GitLab API token [$SCM_ENGINE_TOKEN, $GITHUB_TOKEN]
+ --base-url value Base URL for the SCM instance (default: "https://gitlab.com/") [$GITLAB_BASEURL, $CI_SERVER_URL, $GITHUB_API_URL]
--dry-run Dry run, don't actually _do_ actions, just print them (default: false)
--help, -h show help
--version, -v print the version
diff --git a/docs/commands/server.md b/docs/commands/server.md
index 039e7bf..bf8ec40 100644
--- a/docs/commands/server.md
+++ b/docs/commands/server.md
@@ -31,8 +31,9 @@ OPTIONS:
GLOBAL OPTIONS:
--config value Path to the scm-engine config file (default: ".scm-engine.yml") [$SCM_ENGINE_CONFIG_FILE]
- --api-token value GitHub/GitLab API token [$SCM_ENGINE_TOKEN]
- --base-url value Base URL for the SCM instance (default: "https://gitlab.com/") [$GITLAB_BASEURL, $CI_SERVER_URL]
+ --provider value SCM provider to use. Must be either "github" or "gitlab". SCM Engine will automatically detect "github" if "GITHUB_ACTIONS" environment variable is set (e.g., inside GitHub Actions) and detect "gitlab" if "GITLAB_CI" environment variable is set (e.g., inside GitLab CI). [$SCM_ENGINE_PROVIDER]
+ --api-token value GitHub/GitLab API token [$SCM_ENGINE_TOKEN, $GITHUB_TOKEN]
+ --base-url value Base URL for the SCM instance (default: "https://gitlab.com/") [$GITLAB_BASEURL, $CI_SERVER_URL, $GITHUB_API_URL]
--dry-run Dry run, don't actually _do_ actions, just print them (default: false)
--help, -h show help
--version, -v print the version
diff --git a/docs/configuration/index.md b/docs/configuration.md
similarity index 97%
rename from docs/configuration/index.md
rename to docs/configuration.md
index 43bc3de..d5a93f1 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration.md
@@ -1,4 +1,4 @@
-# Options
+# Configuration file
The default configuration filename is `.scm-engine.yml`, either in current working directory, or if you are in a Git repository, the root of the project.
@@ -105,7 +105,7 @@ SCM Engine supports two strategies for managing labels, each changes the behavio
Use the `#!yaml conditional` strategy when you want to add/remove a label on a Merge Request depending on *something*. It's the default strategy, and the most simple one to use.
-!!! example "Please see the [*Add label if a file extension is modified*](examples.md#add-label-if-a-file-extension-is-modified) example for how to use this"
+!!! example "Please see the [*Add label if a file extension is modified*](./gitlab/examples.md#add-label-if-a-file-extension-is-modified) example for how to use this"
#### `label[].strategy = generate` {#label.strategy-generate data-toc-label="generate"}
@@ -113,7 +113,7 @@ Use the [`#!yaml generate`](#label.strategy) strategy if you want to create dyna
Thanks to the dynamic nature of the `#!yaml generate` strategy, it has fantastic flexibility, at the cost of greater complexity.
-!!! example "Please see the [*generate labels from directory layout*](examples.md#generate-labels-via-script) example for how to use this"
+!!! example "Please see the [*generate labels from directory layout*](./gitlab/examples.md#generate-labels-via-script) example for how to use this"
### `label[].name` {#label.name data-toc-label="name"}
diff --git a/docs/github/.pages b/docs/github/.pages
new file mode 100644
index 0000000..260cdbb
--- /dev/null
+++ b/docs/github/.pages
@@ -0,0 +1,10 @@
+title: >
+
+
+ GitHub (WIP)
+
+arrange:
+ - setup.md
+ - ...
diff --git a/docs/github/script-functions.md b/docs/github/script-functions.md
new file mode 100644
index 0000000..7fe1637
--- /dev/null
+++ b/docs/github/script-functions.md
@@ -0,0 +1,103 @@
+# Script Functions
+
+!!! tip "The [Expr Language Definition](https://expr-lang.org/docs/language-definition) is a great resource to learn more about the language"
+
+## pull_request
+
+### `pull_request.state_is(string...) -> boolean` {: #pull_request.state_is data-toc-label="state_is"}
+
+Check if the `pull_request` state is any of the provided states
+
+**Valid options**:
+
+- `CLOSED` - In closed state
+- `MERGED` - Pull Request has been merged
+- `OPEN` - Opened Pull Request
+
+```css
+pull_request.state_is("MERGED")
+pull_request.state_is("CLOSED", "MERGED")
+```
+
+### `pull_request.modified_files(string...) -> boolean` {: #pull_request.modified_files data-toc-label="modified_files"}
+
+Returns wether any of the provided files patterns have been modified in the Pull Request.
+
+The file patterns use the [`.gitignore` format](https://git-scm.com/docs/gitignore#_pattern_format).
+
+```css
+pull_request.modified_files("*.go", "docs/") == true
+```
+
+### `pull_request.modified_files_list(string...) -> []string` {: #pull_request.modified_files_list data-toc-label="modified_files_list"}
+
+Returns an array of files matching the provided (optional) pattern thas has been modified in the Pull Request.
+
+The file patterns use the [`.gitignore` format](https://git-scm.com/docs/gitignore#_pattern_format).
+
+```css
+pull_request.modified_files_list("*.go", "docs/") == ["example/file.go", "docs/index.md"]
+```
+
+### `pull_request.has_label(string) -> boolean` {: #pull_request.has_label data-toc-label="has_label"}
+
+Returns wether any of the provided label exist on the Pull Request.
+
+```css
+pull_request.labels = ["hello"]
+pull_request.has_label("hello") == true
+pull_request.has_label("world") == false
+```
+
+### `pull_request.has_no_label(string) -> boolean` {: #pull_request.has_no_label data-toc-label="has_no_label"}
+
+Returns wether the Pull Request has the provided label or not.
+
+```css
+pull_request.labels = ["hello"]
+pull_request.has_no_label("hello") == false
+pull_request.has_no_label("world") == true
+```
+
+## Global
+
+### `duration(string) -> duration` {: #duration data-toc-label="duration"}
+
+Returns the [`time.Duration`](https://pkg.go.dev/time#Duration) value of the given string str.
+
+Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h", "d" and "w".
+
+```css
+duration("1h").Seconds() == 3600
+```
+
+### `uniq([]string) -> []string` {: #uniq data-toc-label="uniq"}
+
+Returns a new array where all duplicate values has been removed.
+
+```css
+(["hello", "world", "world"] | uniq) == ["hello", "world"]
+```
+
+### `filepath_dir` {: #filepath_dir data-toc-label="filepath_dir"}
+
+`filepath_dir` returns all but the last element of path, typically the path's directory. After dropping the final element,
+
+Dir calls [Clean](https://pkg.go.dev/path/filepath#Clean) on the path and trailing slashes are removed.
+
+If the path is empty, `filepath_dir` returns ".". If the path consists entirely of separators, `filepath_dir` returns a single separator.
+
+The returned path does not end in a separator unless it is the root directory.
+
+```css
+filepath_dir("example/directory/file.go") == "example/directory"
+```
+
+### `limit_path_depth_to` {: #limit_path_depth_to data-toc-label="limit_path_depth_to"}
+
+`limit_path_depth_to` takes a path structure, and limits it to the configured maximum depth. Particularly useful when using `generated` labels from a directory structure, and want to to have a label naming scheme that only uses path of the path.
+
+```css
+limit_path_depth_to("path1/path2/path3/path4", 2), == "path1/path2"
+limit_path_depth_to("path1/path2", 3), == "path1/path2"
+```
diff --git a/docs/github/setup.md b/docs/github/setup.md
new file mode 100644
index 0000000..2cb6189
--- /dev/null
+++ b/docs/github/setup.md
@@ -0,0 +1,3 @@
+# Setup
+
+TODO
diff --git a/docs/gitlab/.pages b/docs/gitlab/.pages
index 110800a..ad7e9dc 100644
--- a/docs/gitlab/.pages
+++ b/docs/gitlab/.pages
@@ -1,3 +1,27 @@
+title: >
+
+
+ GitLab
+
arrange:
- setup.md
- ...
diff --git a/docs/configuration/examples.md b/docs/gitlab/examples.md
similarity index 100%
rename from docs/configuration/examples.md
rename to docs/gitlab/examples.md
diff --git a/docs/configuration/snippets/close-merge-request/close-if.expr b/docs/gitlab/snippets/close-merge-request/close-if.expr
similarity index 100%
rename from docs/configuration/snippets/close-merge-request/close-if.expr
rename to docs/gitlab/snippets/close-merge-request/close-if.expr
diff --git a/docs/configuration/snippets/close-merge-request/label-script.expr b/docs/gitlab/snippets/close-merge-request/label-script.expr
similarity index 100%
rename from docs/configuration/snippets/close-merge-request/label-script.expr
rename to docs/gitlab/snippets/close-merge-request/label-script.expr
diff --git a/docs/configuration/snippets/close-merge-request/warn-if.expr b/docs/gitlab/snippets/close-merge-request/warn-if.expr
similarity index 100%
rename from docs/configuration/snippets/close-merge-request/warn-if.expr
rename to docs/gitlab/snippets/close-merge-request/warn-if.expr
diff --git a/docs/index.md b/docs/index.md
index 25c9f1c..a5f9164 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,10 +17,10 @@ hide:
## What does it look like?
-!!! tip "Please see the [Configuration Examples page](configuration/examples.md) for more use-cases"
+!!! tip "Please see the [Configuration Examples page](gitlab/examples.md) for more use-cases"
-!!! info "Please see the [Configuration Options page](configuration/index.md) for all options and explanations"
+!!! info "Please see the [Configuration Options page](configuration.md) for all options and explanations"
```yaml
---8<-- ".scm-engine.example.yml"
+--8<-- ".scm-engine.gitlab.example.yml"
```
diff --git a/go.mod b/go.mod
index 0d4f9a1..4138567 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
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/google/go-github/v62 v62.0.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 a9ee196..192a7de 100644
--- a/go.sum
+++ b/go.sum
@@ -26,8 +26,10 @@ github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
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=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4=
+github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
diff --git a/main.go b/main.go
index 7b8f9ed..b91f5ae 100644
--- a/main.go
+++ b/main.go
@@ -52,12 +52,21 @@ func main() {
"SCM_ENGINE_CONFIG_FILE",
},
},
+ &cli.StringFlag{
+ Name: cmd.FlagProvider,
+ Usage: `SCM provider to use. Must be either "github" or "gitlab". SCM Engine will automatically detect "github" if "GITHUB_ACTIONS" environment variable is set (e.g., inside GitHub Actions) and detect "gitlab" if "GITLAB_CI" environment variable is set (e.g., inside GitLab CI).`,
+ Value: detectProviderFromEnv(),
+ EnvVars: []string{
+ "SCM_ENGINE_PROVIDER",
+ },
+ },
&cli.StringFlag{
Name: cmd.FlagAPIToken,
Usage: "GitHub/GitLab API token",
Required: true,
EnvVars: []string{
- "SCM_ENGINE_TOKEN",
+ "SCM_ENGINE_TOKEN", // SCM Engine Native
+ "GITHUB_TOKEN", // GitHub Actions
},
},
&cli.StringFlag{
@@ -66,7 +75,8 @@ func main() {
Value: "https://gitlab.com/",
EnvVars: []string{
"GITLAB_BASEURL",
- "CI_SERVER_URL",
+ "CI_SERVER_URL", // GitLab CI
+ "GITHUB_API_URL", // GitHub Actions
},
},
&cli.BoolFlag{
@@ -83,13 +93,22 @@ func main() {
ArgsUsage: " [mr_id, mr_id, ...]",
Action: cmd.Evaluate,
Flags: []cli.Flag{
+ &cli.BoolFlag{
+ Name: cmd.FlagUpdatePipeline,
+ Usage: "Update the CI pipeline status with progress",
+ Value: false,
+ EnvVars: []string{
+ "SCM_ENGINE_UPDATE_PIPELINE",
+ },
+ },
&cli.StringFlag{
Name: cmd.FlagSCMProject,
Usage: "GitLab project (example: 'gitlab-org/gitlab')",
Required: true,
EnvVars: []string{
"GITLAB_PROJECT",
- "CI_PROJECT_PATH",
+ "CI_PROJECT_PATH", // GitLab CI
+ "GITHUB_REPOSITORY", // GitHub Actions
},
},
&cli.StringFlag{
@@ -104,14 +123,7 @@ func main() {
Usage: "The git commit sha",
EnvVars: []string{
"CI_COMMIT_SHA", // GitLab CI
- },
- },
- &cli.BoolFlag{
- Name: cmd.FlagUpdatePipeline,
- Usage: "Update the CI pipeline status with progress",
- Value: false,
- EnvVars: []string{
- "SCM_ENGINE_UPDATE_PIPELINE",
+ "GITHUB_SHA", // GitHub Actions
},
},
},
@@ -162,3 +174,19 @@ func main() {
log.Fatal(err)
}
}
+
+func detectProviderFromEnv() string {
+ if _, ok := os.LookupEnv("SCM_ENGINE_DONT_DETECT_PROVIDER"); ok {
+ return ""
+ }
+
+ if _, ok := os.LookupEnv("GITHUB_ACTIONS"); ok {
+ return "github"
+ }
+
+ if _, ok := os.LookupEnv("GITLAB_CI"); ok {
+ return "gitlab"
+ }
+
+ return ""
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index 3c83286..ec6c5d2 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -11,13 +11,14 @@ dev_addr: "0.0.0.0:8000"
nav:
- index.md
- install.md
- - ... | configuration/*.md
+ - configuration.md
- ... | commands/*.md
+ - ... | github/*.md
- ... | gitlab/*.md
plugins:
- awesome-pages: # pip install mkdocs-awesome-pages-plugin
- collapse_single_pages: true
+ collapse_single_pages: false
strict: true
- search:
separator: '[\s\u200b\-_,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
diff --git a/pkg/scm/github/client.go b/pkg/scm/github/client.go
new file mode 100644
index 0000000..ff34b78
--- /dev/null
+++ b/pkg/scm/github/client.go
@@ -0,0 +1,65 @@
+package github
+
+import (
+ "context"
+
+ go_github "github.com/google/go-github/v62/github"
+ "github.com/jippi/scm-engine/pkg/scm"
+ "github.com/jippi/scm-engine/pkg/state"
+)
+
+// Ensure the GitLab client implements the [scm.Client]
+var _ scm.Client = (*Client)(nil)
+
+// Client is a wrapper around the GitLab specific implementation of [scm.Client] interface
+type Client struct {
+ wrapped *go_github.Client
+
+ labels *LabelClient
+ mergeRequests *MergeRequestClient
+}
+
+// NewClient creates a new GitLab client
+func NewClient(ctx context.Context) *Client {
+ client := go_github.NewClient(nil).WithAuthToken(state.Token(ctx))
+
+ return &Client{wrapped: client}
+}
+
+// Labels returns a client target at managing labels/tags
+func (client *Client) Labels() scm.LabelClient {
+ if client.labels == nil {
+ client.labels = NewLabelClient(client)
+ }
+
+ return client.labels
+}
+
+// MergeRequests returns a client target at managing merge/pull requests
+func (client *Client) MergeRequests() scm.MergeRequestClient {
+ if client.mergeRequests == nil {
+ client.mergeRequests = NewMergeRequestClient(client)
+ }
+
+ return client.mergeRequests
+}
+
+// EvalContext creates a new evaluation context for GitLab specific usage
+func (client *Client) EvalContext(ctx context.Context) (scm.EvalContext, error) {
+ res, err := NewContext(ctx, "https://api.github.com/", state.Token(ctx))
+ if err != nil {
+ return nil, err
+ }
+
+ return res, nil
+}
+
+// Start pipeline
+func (client *Client) Start(ctx context.Context) error {
+ return nil
+}
+
+// Stop pipeline
+func (client *Client) Stop(ctx context.Context, err error) error {
+ return nil
+}
diff --git a/pkg/scm/github/client_actioner.go b/pkg/scm/github/client_actioner.go
new file mode 100644
index 0000000..2413c18
--- /dev/null
+++ b/pkg/scm/github/client_actioner.go
@@ -0,0 +1,137 @@
+package github
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+
+ go_github "github.com/google/go-github/v62/github"
+ "github.com/jippi/scm-engine/pkg/scm"
+ "github.com/jippi/scm-engine/pkg/state"
+ slogctx "github.com/veqryn/slog-context"
+)
+
+func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOptions, step scm.EvaluationActionStep) error {
+ owner, repo := ownerAndRepo(ctx)
+
+ action, ok := step["action"]
+ if !ok {
+ return errors.New("step is missing an 'action' key")
+ }
+
+ actionString, ok := action.(string)
+ if !ok {
+ return fmt.Errorf("step field 'action' must be of type string, got %T", action)
+ }
+
+ switch actionString {
+ case "add_label":
+ name, ok := step["name"]
+ if !ok {
+ return errors.New("step field 'name' is required, but missing")
+ }
+
+ nameVal, ok := name.(string)
+ if !ok {
+ return errors.New("step field 'name' must be a string")
+ }
+
+ labels := update.AddLabels
+ if labels == nil {
+ labels = &scm.LabelOptions{}
+ }
+
+ tmp := append(*labels, nameVal)
+
+ update.AddLabels = &tmp
+
+ case "remove_label":
+ name, ok := step["name"]
+ if !ok {
+ return errors.New("step field 'name' is required, but missing")
+ }
+
+ nameVal, ok := name.(string)
+ if !ok {
+ return errors.New("step field 'name' must be a string")
+ }
+
+ labels := update.RemoveLabels
+ if labels == nil {
+ labels = &scm.LabelOptions{}
+ }
+
+ tmp := append(*labels, nameVal)
+
+ update.AddLabels = &tmp
+
+ case "close":
+ update.StateEvent = scm.Ptr("close")
+
+ case "reopen":
+ update.StateEvent = scm.Ptr("reopen")
+
+ case "lock_discussion":
+ update.DiscussionLocked = scm.Ptr(true)
+
+ case "unlock_discussion":
+ update.DiscussionLocked = scm.Ptr(false)
+
+ case "approve":
+ if state.IsDryRun(ctx) {
+ slogctx.Info(ctx, "Approving MR")
+
+ return nil
+ }
+
+ _, _, err := c.wrapped.PullRequests.CreateReview(ctx, owner, repo, state.MergeRequestIDInt(ctx), &go_github.PullRequestReviewRequest{
+ Event: scm.Ptr("APPROVE"),
+ })
+
+ return err
+
+ case "unapprove":
+ if state.IsDryRun(ctx) {
+ slogctx.Info(ctx, "Unapproving MR")
+
+ return nil
+ }
+
+ _, _, err := c.wrapped.PullRequests.CreateReview(ctx, owner, repo, state.MergeRequestIDInt(ctx), &go_github.PullRequestReviewRequest{})
+
+ return err
+
+ case "comment":
+ msg, ok := step["message"]
+ if !ok {
+ return errors.New("step field 'message' is required, but missing")
+ }
+
+ msgString, ok := msg.(string)
+ if !ok {
+ return fmt.Errorf("step field 'message' must be a string, got %T", msg)
+ }
+
+ if len(msgString) == 0 {
+ return errors.New("step field 'message' must not be an empty string")
+ }
+
+ if state.IsDryRun(ctx) {
+ slogctx.Info(ctx, "Commenting on MR", slog.String("message", msgString))
+
+ return nil
+ }
+
+ _, _, err := c.wrapped.PullRequests.CreateComment(ctx, owner, repo, state.MergeRequestIDInt(ctx), &go_github.PullRequestComment{
+ Body: scm.Ptr(msgString),
+ })
+
+ return err
+
+ default:
+ return fmt.Errorf("GitLab client does not know how to apply action %q", step["action"])
+ }
+
+ return nil
+}
diff --git a/pkg/scm/github/client_helpers.go b/pkg/scm/github/client_helpers.go
new file mode 100644
index 0000000..08d7376
--- /dev/null
+++ b/pkg/scm/github/client_helpers.go
@@ -0,0 +1,41 @@
+package github
+
+import (
+ "context"
+ "strings"
+
+ go_github "github.com/google/go-github/v62/github"
+ "github.com/jippi/scm-engine/pkg/scm"
+ "github.com/jippi/scm-engine/pkg/state"
+)
+
+// Convert a GitLab native response to a SCM agnostic one
+func convertResponse(upstream *go_github.Response) *scm.Response {
+ if upstream == nil {
+ return nil
+ }
+
+ return &scm.Response{
+ Response: upstream.Response,
+ // Fields used for offset-based pagination.
+ // TotalItems: upstream.TotalItems,
+ // TotalPages: upstream.TotalPages,
+ // ItemsPerPage: upstream.ItemsPerPage,
+ // CurrentPage: upstream.CurrentPage,
+ NextPage: upstream.NextPage,
+ // PreviousPage: upstream.PreviousPage,
+
+ // Fields used for keyset-based pagination.
+ // PreviousLink: upstream.PreviousLink,
+ // NextLink: upstream.NextLink,
+ // FirstLink: upstream.FirstLink,
+ // LastLink: upstream.LastLink,
+ }
+}
+
+func ownerAndRepo(ctx context.Context) (string, string) {
+ project := state.ProjectID(ctx)
+ chunks := strings.Split(project, "/")
+
+ return chunks[0], chunks[1]
+}
diff --git a/pkg/scm/github/client_label.go b/pkg/scm/github/client_label.go
new file mode 100644
index 0000000..20d6bc3
--- /dev/null
+++ b/pkg/scm/github/client_label.go
@@ -0,0 +1,123 @@
+package github
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+
+ go_github "github.com/google/go-github/v62/github"
+ "github.com/jippi/scm-engine/pkg/scm"
+ slogctx "github.com/veqryn/slog-context"
+)
+
+var _ scm.LabelClient = (*LabelClient)(nil)
+
+type LabelClient struct {
+ client *Client
+
+ cache []*scm.Label
+}
+
+func NewLabelClient(client *Client) *LabelClient {
+ return &LabelClient{client: client}
+}
+
+func (client *LabelClient) List(ctx context.Context) ([]*scm.Label, error) {
+ // Check cache
+ if len(client.cache) != 0 {
+ return client.cache, nil
+ }
+
+ var results []*scm.Label
+
+ // Load all existing labels
+ opts := &scm.ListLabelsOptions{
+ IncludeAncestorGroups: scm.Ptr(true),
+ ListOptions: scm.ListOptions{
+ PerPage: 100,
+ Page: 1,
+ },
+ }
+
+ for {
+ slogctx.Info(ctx, "Reading labels page", slog.Int("page", opts.Page))
+
+ labels, resp, err := client.list(ctx, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ results = append(results, labels...)
+
+ if resp.NextPage == 0 {
+ break
+ }
+
+ opts.ListOptions.Page = resp.NextPage
+ }
+
+ // Store cache
+ client.cache = results
+
+ return results, nil
+}
+
+func (client *LabelClient) list(ctx context.Context, opt *scm.ListLabelsOptions) ([]*scm.Label, *scm.Response, error) {
+ owner, repo := ownerAndRepo(ctx)
+
+ githubLabels, response, err := client.client.wrapped.Issues.ListLabels(ctx, owner, repo, &go_github.ListOptions{PerPage: opt.PerPage, Page: opt.Page})
+ if err != nil {
+ return nil, convertResponse(response), err
+ }
+
+ labels := make([]*scm.Label, 0)
+
+ for _, label := range githubLabels {
+ labels = append(labels, convertLabel(label))
+ }
+
+ return labels, convertResponse(response), nil
+}
+
+func (client *LabelClient) Create(ctx context.Context, opt *scm.CreateLabelOptions) (*scm.Label, *scm.Response, error) {
+ // Invalidate cache
+ client.cache = nil
+
+ owner, repo := ownerAndRepo(ctx)
+
+ label, resp, err := client.client.wrapped.Issues.CreateLabel(ctx, owner, repo, &go_github.Label{
+ Name: opt.Name,
+ Description: opt.Description,
+ Color: scm.Ptr(strings.TrimPrefix(*opt.Color, "#")),
+ })
+
+ return convertLabel(label), convertResponse(resp), err
+}
+
+func (client *LabelClient) Update(ctx context.Context, opt *scm.UpdateLabelOptions) (*scm.Label, *scm.Response, error) {
+ // Invalidate cache
+ client.cache = nil
+
+ owner, repo := ownerAndRepo(ctx)
+
+ updateLabel := &go_github.Label{}
+ updateLabel.Name = opt.Name
+ updateLabel.Color = scm.Ptr(strings.TrimPrefix(*opt.Color, "#"))
+ updateLabel.Description = opt.Description
+
+ label, resp, err := client.client.wrapped.Issues.EditLabel(ctx, owner, repo, *opt.Name, updateLabel)
+
+ return convertLabel(label), convertResponse(resp), err
+}
+
+func convertLabel(label *go_github.Label) *scm.Label {
+ if label == nil {
+ return nil
+ }
+
+ return &scm.Label{
+ Name: *label.Name,
+ Description: *label.Description,
+ Color: *label.Color,
+ }
+}
diff --git a/pkg/scm/github/client_pull_request.go b/pkg/scm/github/client_pull_request.go
new file mode 100644
index 0000000..1699340
--- /dev/null
+++ b/pkg/scm/github/client_pull_request.go
@@ -0,0 +1,62 @@
+package github
+
+import (
+ "context"
+ "io"
+ "net/http"
+
+ go_github "github.com/google/go-github/v62/github"
+ "github.com/jippi/scm-engine/pkg/scm"
+ "github.com/jippi/scm-engine/pkg/state"
+)
+
+var _ scm.MergeRequestClient = (*MergeRequestClient)(nil)
+
+type MergeRequestClient struct {
+ client *Client
+}
+
+func NewMergeRequestClient(client *Client) *MergeRequestClient {
+ return &MergeRequestClient{client: client}
+}
+
+func (client *MergeRequestClient) Update(ctx context.Context, opt *scm.UpdateMergeRequestOptions) (*scm.Response, error) {
+ owner, repo := ownerAndRepo(ctx)
+
+ // Add labels
+ if _, resp, err := client.client.wrapped.Issues.AddLabelsToIssue(ctx, owner, repo, state.MergeRequestIDInt(ctx), *opt.AddLabels); err != nil {
+ return convertResponse(resp), err
+ }
+
+ // Remove labels
+ if opt.RemoveLabels != nil && len(*opt.RemoveLabels) > 0 {
+ for _, label := range *opt.RemoveLabels {
+ if resp, err := client.client.wrapped.Issues.RemoveLabelForIssue(ctx, owner, repo, state.MergeRequestIDInt(ctx), label); err != nil {
+ switch resp.StatusCode {
+ case http.StatusNotFound:
+ // Ignore
+
+ default:
+ return convertResponse(resp), err
+ }
+ }
+ }
+ }
+
+ // Update MR
+ updatePullRequest := &go_github.PullRequest{
+ Locked: opt.DiscussionLocked,
+ }
+
+ _, resp, err := client.client.wrapped.PullRequests.Edit(ctx, owner, repo, state.MergeRequestIDInt(ctx), updatePullRequest)
+
+ return convertResponse(resp), err
+}
+
+func (client *MergeRequestClient) GetRemoteConfig(ctx context.Context, filename, ref string) (io.Reader, error) {
+ return nil, nil
+}
+
+func (client *MergeRequestClient) List(ctx context.Context, options *scm.ListMergeRequestsOptions) ([]scm.ListMergeRequest, error) {
+ return nil, nil
+}
diff --git a/pkg/scm/github/context.go b/pkg/scm/github/context.go
new file mode 100644
index 0000000..a129da8
--- /dev/null
+++ b/pkg/scm/github/context.go
@@ -0,0 +1,86 @@
+package github
+
+import (
+ "context"
+ "time"
+
+ "github.com/hasura/go-graphql-client"
+ "github.com/jippi/scm-engine/pkg/scm"
+ "github.com/jippi/scm-engine/pkg/state"
+ "golang.org/x/oauth2"
+)
+
+var _ scm.EvalContext = (*Context)(nil)
+
+func NewContext(ctx context.Context, _, token string) (*Context, error) {
+ httpClient := oauth2.NewClient(
+ ctx,
+ oauth2.StaticTokenSource(
+ &oauth2.Token{
+ AccessToken: token,
+ },
+ ),
+ )
+
+ owner, repo := ownerAndRepo(ctx)
+
+ client := graphql.NewClient("https://api.github.com/graphql", httpClient)
+
+ var (
+ evalContext *Context
+ variables = map[string]any{
+ "owner": owner,
+ "repo": repo,
+ "pr": state.MergeRequestIDInt(ctx),
+ }
+ )
+
+ if err := client.Query(ctx, &evalContext, variables); err != nil {
+ return nil, err
+ }
+
+ // move PullRequest to root context
+ evalContext.PullRequest = evalContext.Repository.PullRequest
+ evalContext.Repository.PullRequest = nil
+
+ // Move 'files' to MR context without nesting
+ evalContext.PullRequest.Files = evalContext.PullRequest.ResponseFiles.Nodes
+ evalContext.PullRequest.ResponseFiles = nil
+
+ // Move 'labels' to MR context without nesting
+ evalContext.PullRequest.Labels = evalContext.PullRequest.ResponseLabels.Nodes
+ evalContext.PullRequest.ResponseLabels = nil
+
+ if len(evalContext.PullRequest.ResponseFirstCommits.Nodes) > 0 {
+ evalContext.PullRequest.FirstCommit = evalContext.PullRequest.ResponseFirstCommits.Nodes[0].Commit
+
+ tmp := time.Since(evalContext.PullRequest.FirstCommit.CommittedDate)
+ evalContext.PullRequest.TimeSinceFirstCommit = &tmp
+ }
+
+ evalContext.PullRequest.ResponseFirstCommits = nil
+
+ if len(evalContext.PullRequest.ResponseLastCommits.Nodes) > 0 {
+ evalContext.PullRequest.LastCommit = evalContext.PullRequest.ResponseLastCommits.Nodes[0].Commit
+
+ tmp := time.Since(evalContext.PullRequest.LastCommit.CommittedDate)
+ evalContext.PullRequest.TimeSinceLastCommit = &tmp
+ }
+
+ evalContext.PullRequest.ResponseLastCommits = nil
+
+ if evalContext.PullRequest.FirstCommit != nil && evalContext.PullRequest.LastCommit != nil {
+ tmp := evalContext.PullRequest.FirstCommit.CommittedDate.Sub(evalContext.PullRequest.LastCommit.CommittedDate).Round(time.Hour)
+ evalContext.PullRequest.TimeBetweenFirstAndLastCommit = &tmp
+ }
+
+ return evalContext, nil
+}
+
+func (c *Context) IsValid() bool {
+ return c != nil
+}
+
+func (c *Context) SetWebhookEvent(in any) {
+ c.WebhookEvent = in
+}
diff --git a/pkg/scm/github/context_pull_request.go b/pkg/scm/github/context_pull_request.go
new file mode 100644
index 0000000..f6d8130
--- /dev/null
+++ b/pkg/scm/github/context_pull_request.go
@@ -0,0 +1,57 @@
+package github
+
+import (
+ "fmt"
+
+ "github.com/jippi/scm-engine/pkg/scm"
+)
+
+func (e ContextPullRequest) IsApproved() bool {
+ return e.ReviewDecision == PullRequestReviewDecisionApproved
+}
+
+func (e ContextPullRequest) StateIs(anyOf ...string) bool {
+ for _, state := range anyOf {
+ if !PullRequestState(state).IsValid() {
+ panic(fmt.Errorf("unknown state value: %q", state))
+ }
+
+ if state == e.State.String() {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (e ContextPullRequest) HasLabel(in string) bool {
+ for _, label := range e.Labels {
+ if label.Name == in {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (e ContextPullRequest) HasNoLabel(in string) bool {
+ return !e.HasLabel(in)
+}
+
+func (e ContextPullRequest) ModifiedFilesList(patterns ...string) []string {
+ return e.findModifiedFiles(patterns...)
+}
+
+// Partially lifted from https://github.com/hmarr/codeowners/blob/main/match.go
+func (e ContextPullRequest) ModifiedFiles(patterns ...string) bool {
+ return len(e.findModifiedFiles(patterns...)) > 0
+}
+
+func (e ContextPullRequest) findModifiedFiles(patterns ...string) []string {
+ files := []string{}
+ for _, f := range e.Files {
+ files = append(files, f.Path)
+ }
+
+ return scm.FindModifiedFiles(files, patterns...)
+}
diff --git a/pkg/scm/gitlab/client.go b/pkg/scm/gitlab/client.go
index 1514cd3..7d31b1a 100644
--- a/pkg/scm/gitlab/client.go
+++ b/pkg/scm/gitlab/client.go
@@ -13,7 +13,7 @@ import (
go_gitlab "github.com/xanzy/go-gitlab"
)
-var pipelineName = go_gitlab.Ptr("scm-engine")
+var pipelineName = scm.Ptr("scm-engine")
// Ensure the GitLab client implements the [scm.Client]
var _ scm.Client = (*Client)(nil)
@@ -21,20 +21,19 @@ var _ scm.Client = (*Client)(nil)
// Client is a wrapper around the GitLab specific implementation of [scm.Client] interface
type Client struct {
wrapped *go_gitlab.Client
- token string
labels *LabelClient
mergeRequests *MergeRequestClient
}
// NewClient creates a new GitLab client
-func NewClient(token, baseurl string) (*Client, error) {
- client, err := go_gitlab.NewClient(token, go_gitlab.WithBaseURL(baseurl))
+func NewClient(ctx context.Context) (*Client, error) {
+ client, err := go_gitlab.NewClient(state.Token(ctx), go_gitlab.WithBaseURL(state.BaseURL(ctx)))
if err != nil {
return nil, err
}
- return &Client{wrapped: client, token: token}, nil
+ return &Client{wrapped: client}, nil
}
// Labels returns a client target at managing labels/tags
@@ -57,7 +56,7 @@ func (client *Client) MergeRequests() scm.MergeRequestClient {
// EvalContext creates a new evaluation context for GitLab specific usage
func (client *Client) EvalContext(ctx context.Context) (scm.EvalContext, error) {
- res, err := NewContext(ctx, graphqlBaseURL(client.wrapped.BaseURL()), client.token)
+ res, err := NewContext(ctx, graphqlBaseURL(client.wrapped.BaseURL()), state.Token(ctx))
if err != nil {
return nil, err
}
@@ -74,7 +73,7 @@ func (client *Client) Start(ctx context.Context) error {
_, response, err := client.wrapped.Commits.SetCommitStatus(state.ProjectID(ctx), state.CommitSHA(ctx), &go_gitlab.SetCommitStatusOptions{
State: go_gitlab.Running,
Context: pipelineName,
- Description: go_gitlab.Ptr("Currently evaluating MR"),
+ Description: scm.Ptr("Currently evaluating MR"),
})
switch response.StatusCode {
@@ -107,7 +106,7 @@ func (client *Client) Stop(ctx context.Context, err error) error {
_, response, err := client.wrapped.Commits.SetCommitStatus(state.ProjectID(ctx), state.CommitSHA(ctx), &go_gitlab.SetCommitStatusOptions{
State: status,
Context: pipelineName,
- Description: go_gitlab.Ptr(message),
+ Description: scm.Ptr(message),
})
switch response.StatusCode {
diff --git a/pkg/scm/gitlab/client_actioner.go b/pkg/scm/gitlab/client_actioner.go
index cb7a259..cf8c663 100644
--- a/pkg/scm/gitlab/client_actioner.go
+++ b/pkg/scm/gitlab/client_actioner.go
@@ -65,16 +65,16 @@ func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOp
update.AddLabels = &tmp
case "close":
- update.StateEvent = gitlab.Ptr("close")
+ update.StateEvent = scm.Ptr("close")
case "reopen":
- update.StateEvent = gitlab.Ptr("reopen")
+ update.StateEvent = scm.Ptr("reopen")
case "lock_discussion":
- update.DiscussionLocked = gitlab.Ptr(true)
+ update.DiscussionLocked = scm.Ptr(true)
case "unlock_discussion":
- update.DiscussionLocked = gitlab.Ptr(false)
+ update.DiscussionLocked = scm.Ptr(false)
case "approve":
if state.IsDryRun(ctx) {
@@ -120,7 +120,7 @@ func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOp
}
_, _, err := c.wrapped.Notes.CreateMergeRequestNote(state.ProjectID(ctx), state.MergeRequestIDInt(ctx), &gitlab.CreateMergeRequestNoteOptions{
- Body: gitlab.Ptr(msgString),
+ Body: scm.Ptr(msgString),
})
return err
diff --git a/pkg/scm/gitlab/client_label.go b/pkg/scm/gitlab/client_label.go
index 7f7fa12..c6f28f5 100644
--- a/pkg/scm/gitlab/client_label.go
+++ b/pkg/scm/gitlab/client_label.go
@@ -34,7 +34,7 @@ func (client *LabelClient) List(ctx context.Context) ([]*scm.Label, error) {
// Load all existing labels
opts := &scm.ListLabelsOptions{
- IncludeAncestorGroups: go_gitlab.Ptr(true),
+ IncludeAncestorGroups: scm.Ptr(true),
ListOptions: scm.ListOptions{
PerPage: 100,
Page: 1,
diff --git a/pkg/scm/gitlab/client_merge_request.go b/pkg/scm/gitlab/client_merge_request.go
index 9ed94ce..f2936c7 100644
--- a/pkg/scm/gitlab/client_merge_request.go
+++ b/pkg/scm/gitlab/client_merge_request.go
@@ -54,7 +54,7 @@ func (client *MergeRequestClient) GetRemoteConfig(ctx context.Context, filename,
return nil, err
}
- file, _, err := client.client.wrapped.RepositoryFiles.GetRawFile(project, filename, &go_gitlab.GetRawFileOptions{Ref: go_gitlab.Ptr(ref)})
+ file, _, err := client.client.wrapped.RepositoryFiles.GetRawFile(project, filename, &go_gitlab.GetRawFileOptions{Ref: scm.Ptr(ref)})
if err != nil {
return nil, err
}
@@ -67,7 +67,7 @@ func (client *MergeRequestClient) List(ctx context.Context, options *scm.ListMer
ctx,
oauth2.StaticTokenSource(
&oauth2.Token{
- AccessToken: client.client.token,
+ AccessToken: state.Token(ctx),
},
),
)
diff --git a/pkg/scm/gitlab/context_merge_request.go b/pkg/scm/gitlab/context_merge_request.go
index e4220dc..ab614fa 100644
--- a/pkg/scm/gitlab/context_merge_request.go
+++ b/pkg/scm/gitlab/context_merge_request.go
@@ -1,13 +1,10 @@
package gitlab
import (
- "errors"
"fmt"
- "path/filepath"
- "regexp"
- "strings"
"time"
+ "github.com/jippi/scm-engine/pkg/scm"
"github.com/jippi/scm-engine/pkg/stdlib"
)
@@ -108,194 +105,11 @@ func (e ContextMergeRequest) ModifiedFiles(patterns ...string) bool {
return len(e.findModifiedFiles(patterns...)) > 0
}
-// Partially lifted from https://github.com/hmarr/codeowners/blob/main/match.go
func (e ContextMergeRequest) findModifiedFiles(patterns ...string) []string {
- leftAnchoredLiteral := false
-
- output := []string{}
-
- for _, pattern := range patterns {
- if !strings.ContainsAny(pattern, "*?\\") && pattern[0] == '/' {
- leftAnchoredLiteral = true
- }
-
- regex, err := buildPatternRegex(pattern)
- if err != nil {
- panic(err)
- }
-
- NEXT_FILE:
- for _, changedFile := range e.DiffStats {
- // Normalize Windows-style path separators to forward slashes
- testPath := filepath.ToSlash(changedFile.Path)
-
- if leftAnchoredLiteral {
- prefix := pattern
-
- // Strip the leading slash as we're anchored to the root already
- if prefix[0] == '/' {
- prefix = prefix[1:]
- }
-
- // If the pattern ends with a slash we can do a simple prefix match
- if prefix[len(prefix)-1] == '/' && strings.HasPrefix(testPath, prefix) {
- output = append(output, testPath)
-
- continue NEXT_FILE
- }
-
- // If the strings are the same length, check for an exact match
- if len(testPath) == len(prefix) && testPath == prefix {
- output = append(output, testPath)
-
- continue NEXT_FILE
- }
-
- // Otherwise check if the test path is a subdirectory of the pattern
- if len(testPath) > len(prefix) && testPath[len(prefix)] == '/' && testPath[:len(prefix)] == prefix {
- output = append(output, testPath)
-
- continue NEXT_FILE
- }
- }
-
- if regex.MatchString(testPath) {
- output = append(output, testPath)
-
- continue NEXT_FILE
- }
- }
- }
-
- return output
-}
-
-// buildPatternRegex compiles a new regexp object from a gitignore-style pattern string
-func buildPatternRegex(pattern string) (*regexp.Regexp, error) {
- // Handle specific edge cases first
- switch {
- case strings.Contains(pattern, "***"):
- return nil, errors.New("pattern cannot contain three consecutive asterisks")
-
- case pattern == "":
- return nil, errors.New("empty pattern")
-
- // "/" doesn't match anything
- case pattern == "/":
- return regexp.Compile(`\A\z`)
+ files := []string{}
+ for _, f := range e.DiffStats {
+ files = append(files, f.Path)
}
- segs := strings.Split(pattern, "/")
-
- if segs[0] == "" {
- // Leading slash: match is relative to root
- segs = segs[1:]
- } else {
- // No leading slash - check for a single segment pattern, which matches
- // relative to any descendent path (equivalent to a leading **/)
- if len(segs) == 1 || (len(segs) == 2 && segs[1] == "") {
- if segs[0] != "**" {
- segs = append([]string{"**"}, segs...)
- }
- }
- }
-
- if len(segs) > 1 && segs[len(segs)-1] == "" {
- // Trailing slash is equivalent to "/**"
- segs[len(segs)-1] = "**"
- }
-
- sep := "/"
-
- lastSegIndex := len(segs) - 1
- needSlash := false
-
- var regexString strings.Builder
-
- regexString.WriteString(`\A`)
-
- for i, seg := range segs {
- switch seg {
- case "**":
- switch {
- // If the pattern is just "**" we match everything
- case i == 0 && i == lastSegIndex:
- regexString.WriteString(`.+`)
-
- // If the pattern starts with "**" we match any leading path segment
- case i == 0:
- regexString.WriteString(`(?:.+` + sep + `)?`)
-
- needSlash = false
-
- // If the pattern ends with "**" we match any trailing path segment
- case i == lastSegIndex:
- regexString.WriteString(sep + `.*`)
-
- // If the pattern contains "**" we match zero or more path segments
- default:
- regexString.WriteString(`(?:` + sep + `.+)?`)
-
- needSlash = true
- }
-
- case "*":
- if needSlash {
- regexString.WriteString(sep)
- }
-
- // Regular wildcard - match any characters except the separator
- regexString.WriteString(`[^` + sep + `]+`)
-
- needSlash = true
-
- default:
- if needSlash {
- regexString.WriteString(sep)
- }
-
- escape := false
-
- for _, char := range seg {
- if escape {
- escape = false
-
- regexString.WriteString(regexp.QuoteMeta(string(char)))
-
- continue
- }
-
- // Other pathspec implementations handle character classes here (e.g.
- // [AaBb]), but CODEOWNERS doesn't support that so we don't need to
- switch char {
- case '\\':
- escape = true
-
- // Multi-character wildcard
- case '*':
- regexString.WriteString(`[^` + sep + `]*`)
-
- // Single-character wildcard
- case '?':
- regexString.WriteString(`[^` + sep + `]`)
-
- // Regular character
- default:
- regexString.WriteString(regexp.QuoteMeta(string(char)))
- }
- }
-
- if i == lastSegIndex {
- // As there's no trailing slash (that'd hit the '**' case), we
- // need to match descendent paths
- regexString.WriteString(`(?:` + sep + `.*)?`)
- }
-
- needSlash = true
- }
- }
-
- regexString.WriteString(`\z`)
-
- return regexp.Compile(regexString.String())
+ return scm.FindModifiedFiles(files, patterns...)
}
diff --git a/pkg/scm/helpers.go b/pkg/scm/helpers.go
new file mode 100644
index 0000000..02bab80
--- /dev/null
+++ b/pkg/scm/helpers.go
@@ -0,0 +1,205 @@
+package scm
+
+import (
+ "errors"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// Ptr is a helper that returns a pointer to v.
+func Ptr[T any](v T) *T {
+ return &v
+}
+
+// Partially lifted from https://github.com/hmarr/codeowners/blob/main/match.go
+func FindModifiedFiles(files []string, patterns ...string) []string {
+ leftAnchoredLiteral := false
+
+ output := []string{}
+
+ for _, pattern := range patterns {
+ if !strings.ContainsAny(pattern, "*?\\") && pattern[0] == '/' {
+ leftAnchoredLiteral = true
+ }
+
+ regex, err := buildPatternRegex(pattern)
+ if err != nil {
+ panic(err)
+ }
+
+ NEXT_FILE:
+ for _, changedFile := range files {
+ // Normalize Windows-style path separators to forward slashes
+ testPath := filepath.ToSlash(changedFile)
+
+ if leftAnchoredLiteral {
+ prefix := pattern
+
+ // Strip the leading slash as we're anchored to the root already
+ if prefix[0] == '/' {
+ prefix = prefix[1:]
+ }
+
+ // If the pattern ends with a slash we can do a simple prefix match
+ if prefix[len(prefix)-1] == '/' && strings.HasPrefix(testPath, prefix) {
+ output = append(output, testPath)
+
+ continue NEXT_FILE
+ }
+
+ // If the strings are the same length, check for an exact match
+ if len(testPath) == len(prefix) && testPath == prefix {
+ output = append(output, testPath)
+
+ continue NEXT_FILE
+ }
+
+ // Otherwise check if the test path is a subdirectory of the pattern
+ if len(testPath) > len(prefix) && testPath[len(prefix)] == '/' && testPath[:len(prefix)] == prefix {
+ output = append(output, testPath)
+
+ continue NEXT_FILE
+ }
+ }
+
+ if regex.MatchString(testPath) {
+ output = append(output, testPath)
+
+ continue NEXT_FILE
+ }
+ }
+ }
+
+ return output
+}
+
+// buildPatternRegex compiles a new regexp object from a gitignore-style pattern string
+func buildPatternRegex(pattern string) (*regexp.Regexp, error) {
+ // Handle specific edge cases first
+ switch {
+ case strings.Contains(pattern, "***"):
+ return nil, errors.New("pattern cannot contain three consecutive asterisks")
+
+ case pattern == "":
+ return nil, errors.New("empty pattern")
+
+ // "/" doesn't match anything
+ case pattern == "/":
+ return regexp.Compile(`\A\z`)
+ }
+
+ segs := strings.Split(pattern, "/")
+
+ if segs[0] == "" {
+ // Leading slash: match is relative to root
+ segs = segs[1:]
+ } else {
+ // No leading slash - check for a single segment pattern, which matches
+ // relative to any descendent path (equivalent to a leading **/)
+ if len(segs) == 1 || (len(segs) == 2 && segs[1] == "") {
+ if segs[0] != "**" {
+ segs = append([]string{"**"}, segs...)
+ }
+ }
+ }
+
+ if len(segs) > 1 && segs[len(segs)-1] == "" {
+ // Trailing slash is equivalent to "/**"
+ segs[len(segs)-1] = "**"
+ }
+
+ sep := "/"
+
+ lastSegIndex := len(segs) - 1
+ needSlash := false
+
+ var regexString strings.Builder
+
+ regexString.WriteString(`\A`)
+
+ for i, seg := range segs {
+ switch seg {
+ case "**":
+ switch {
+ // If the pattern is just "**" we match everything
+ case i == 0 && i == lastSegIndex:
+ regexString.WriteString(`.+`)
+
+ // If the pattern starts with "**" we match any leading path segment
+ case i == 0:
+ regexString.WriteString(`(?:.+` + sep + `)?`)
+
+ needSlash = false
+
+ // If the pattern ends with "**" we match any trailing path segment
+ case i == lastSegIndex:
+ regexString.WriteString(sep + `.*`)
+
+ // If the pattern contains "**" we match zero or more path segments
+ default:
+ regexString.WriteString(`(?:` + sep + `.+)?`)
+
+ needSlash = true
+ }
+
+ case "*":
+ if needSlash {
+ regexString.WriteString(sep)
+ }
+
+ // Regular wildcard - match any characters except the separator
+ regexString.WriteString(`[^` + sep + `]+`)
+
+ needSlash = true
+
+ default:
+ if needSlash {
+ regexString.WriteString(sep)
+ }
+
+ escape := false
+
+ for _, char := range seg {
+ if escape {
+ escape = false
+
+ regexString.WriteString(regexp.QuoteMeta(string(char)))
+
+ continue
+ }
+
+ // Other pathspec implementations handle character classes here (e.g.
+ // [AaBb]), but CODEOWNERS doesn't support that so we don't need to
+ switch char {
+ case '\\':
+ escape = true
+
+ // Multi-character wildcard
+ case '*':
+ regexString.WriteString(`[^` + sep + `]*`)
+
+ // Single-character wildcard
+ case '?':
+ regexString.WriteString(`[^` + sep + `]`)
+
+ // Regular character
+ default:
+ regexString.WriteString(regexp.QuoteMeta(string(char)))
+ }
+ }
+
+ if i == lastSegIndex {
+ // As there's no trailing slash (that'd hit the '**' case), we
+ // need to match descendent paths
+ regexString.WriteString(`(?:` + sep + `.*)?`)
+ }
+
+ needSlash = true
+ }
+ }
+
+ regexString.WriteString(`\z`)
+
+ return regexp.Compile(regexString.String())
+}
diff --git a/pkg/scm/types.go b/pkg/scm/types.go
index 0e54b90..cd07b08 100644
--- a/pkg/scm/types.go
+++ b/pkg/scm/types.go
@@ -1,8 +1,11 @@
package scm
import (
+ "context"
"net/http"
+ "strings"
+ "github.com/jippi/scm-engine/pkg/state"
"github.com/jippi/scm-engine/pkg/types"
)
@@ -160,7 +163,7 @@ type EvaluationActionResult struct {
Then []EvaluationActionStep
}
-func (local EvaluationResult) IsEqual(remote *Label) bool {
+func (local EvaluationResult) IsEqual(ctx context.Context, remote *Label) bool {
if local.Name != remote.Name {
return false
}
@@ -169,18 +172,23 @@ func (local EvaluationResult) IsEqual(remote *Label) bool {
return false
}
- if local.Color != remote.Color {
+ // Compare labels without the "#" in the color since GitHub doesn't allow those
+ if strings.TrimPrefix(local.Color, "#") != strings.TrimPrefix(remote.Color, "#") {
return false
}
- // Priority must agree on being NULL or not
- if local.Priority.Valid != remote.Priority.Valid {
- return false
- }
-
- // Priority must agree on their value
- if local.Priority.ValueOrZero() != remote.Priority.ValueOrZero() {
- return false
+ // GitLab supports label priorities, so compare those; however other providers
+ // doesn't support this, so they will ignore it
+ if state.Provider(ctx) == "gitlab" {
+ // Priority must agree on being NULL or not
+ if local.Priority.Valid != remote.Priority.Valid {
+ return false
+ }
+
+ // Priority must agree on their value
+ if local.Priority.ValueOrZero() != remote.Priority.ValueOrZero() {
+ return false
+ }
}
return true
diff --git a/pkg/state/context.go b/pkg/state/context.go
index fae101f..a2fe521 100644
--- a/pkg/state/context.go
+++ b/pkg/state/context.go
@@ -16,6 +16,9 @@ const (
mergeRequestID
commitSha
updatePipeline
+ provider
+ token
+ baseURL
)
func ProjectID(ctx context.Context) string {
@@ -26,6 +29,33 @@ func CommitSHA(ctx context.Context) string {
return ctx.Value(commitSha).(string) //nolint:forcetypeassert
}
+func BaseURL(ctx context.Context) string {
+ return ctx.Value(baseURL).(string) //nolint:forcetypeassert
+}
+
+func WithBaseURL(ctx context.Context, value string) context.Context {
+ return context.WithValue(ctx, baseURL, value)
+}
+
+func Token(ctx context.Context) string {
+ return ctx.Value(token).(string) //nolint:forcetypeassert
+}
+
+func WithToken(ctx context.Context, value string) context.Context {
+ return context.WithValue(ctx, token, value)
+}
+
+func Provider(ctx context.Context) string {
+ return ctx.Value(provider).(string) //nolint:forcetypeassert
+}
+
+func WithProvider(ctx context.Context, value string) context.Context {
+ ctx = slogctx.With(ctx, slog.String("provider", value))
+ ctx = context.WithValue(ctx, provider, value)
+
+ return ctx
+}
+
func WithProjectID(ctx context.Context, value string) context.Context {
ctx = slogctx.With(ctx, slog.String("project_id", value))
ctx = context.WithValue(ctx, projectID, value)
diff --git a/schema/generate.go b/schema/generate.go
index 5c3418d..46c890d 100644
--- a/schema/generate.go
+++ b/schema/generate.go
@@ -1,3 +1,3 @@
-package schema
+package main
-//go:generate go run gitlab.go
+//go:generate go run main.go
diff --git a/schema/github.gqlgen.yml b/schema/github.gqlgen.yml
new file mode 100644
index 0000000..96cc056
--- /dev/null
+++ b/schema/github.gqlgen.yml
@@ -0,0 +1,90 @@
+# Where are all the schema files located? globs are supported eg src/**/*.graphqls
+schema:
+ - "github.schema.graphqls"
+
+# Where should the generated server code go?
+exec:
+ filename: ignore/generated.go
+ package: graph
+
+# Uncomment to enable federation
+# federation:
+# filename: graph/federation.go
+# package: graph
+
+# Where should any generated models go?
+model:
+ filename: ../pkg/scm/github/context.gen.go
+ package: github
+
+# Where should the resolver implementations go?
+resolver:
+ layout: follow-schema
+ dir: ignore/
+ package: graph
+ filename_template: "{name}.resolvers.go"
+# Optional: turn on to not generate template comments above resolvers
+# omit_template_comment: false
+
+# Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models
+# struct_tag: json
+
+# Optional: turn on to use []Thing instead of []*Thing
+omit_slice_element_pointers: true
+
+# Optional: turn on to omit Is() methods to interface and unions
+omit_interface_checks: true
+
+# Optional: turn on to skip generation of ComplexityRoot struct content and Complexity function
+omit_complexity: true
+
+# Optional: turn on to not generate any file notice comments in generated files
+# omit_gqlgen_file_notice: false
+
+# Optional: turn on to exclude the gqlgen version in the generated file notice. No effect if `omit_gqlgen_file_notice` is true.
+# omit_gqlgen_version_in_file_notice: false
+
+# Optional: turn off to make struct-type struct fields not use pointers
+# e.g. type Thing struct { FieldA OtherThing } instead of { FieldA *OtherThing }
+# struct_fields_always_pointers: true
+
+# Optional: turn off to make resolvers return values instead of pointers for structs
+# resolvers_always_return_pointers: true
+
+# Optional: turn on to return pointers instead of values in unmarshalInput
+# return_pointers_in_unmarshalinput: false
+
+# Optional: wrap nullable input fields with Omittable
+# nullable_input_omittable: true
+
+# Optional: set to speed up generation time by not performing a final validation pass.
+# skip_validation: true
+
+# Optional: set to skip running `go mod tidy` when generating server code
+skip_mod_tidy: true
+
+# gqlgen will search for any type names in the schema in these go packages
+# if they match it will use them, otherwise it will generate them.
+autobind:
+# - "github.com/jippi/scm-engine/graph/model"
+
+# This section declares type mapping between the GraphQL and go type systems
+#
+# The first line in each type will be used as defaults for resolver arguments and
+# modelgen, the others will be allowed when binding to fields. Configure them to
+# your liking
+models:
+ ID:
+ model:
+ - github.com/99designs/gqlgen/graphql.ID
+ - github.com/99designs/gqlgen/graphql.Int
+ - github.com/99designs/gqlgen/graphql.Int64
+ - github.com/99designs/gqlgen/graphql.Int32
+ Int:
+ model:
+ - github.com/99designs/gqlgen/graphql.Int
+ - github.com/99designs/gqlgen/graphql.Int64
+ - github.com/99designs/gqlgen/graphql.Int32
+ Duration:
+ model:
+ - github.com/99designs/gqlgen/graphql.Duration
diff --git a/schema/github.schema.graphqls b/schema/github.schema.graphqls
new file mode 100644
index 0000000..40bbaa9
--- /dev/null
+++ b/schema/github.schema.graphqls
@@ -0,0 +1,434 @@
+# @generated fields are constructed in Go, and do not come from the GraphQL endpoint.
+directive @generated on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
+
+# @internal is not exposed in expr scope, and is only available within Go code
+# this is often used when needing to grab data from GraphQL, but not wanting to expose
+# it directly (e.g. due to nesting)
+directive @internal on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
+
+# @expr changes the name of the field when its exposed to expr scope.
+# When omitted (and @internal is not used) we automatically convert the field
+# from "CamelCaseName" to "snake_case_name" in code generation step
+directive @expr(key: String!) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
+
+# @graph changes the name (and query) of the field when sending it to the GraphQL server.
+# This case be used to impose limits in "connections" or providing filtering keys.
+directive @graphql(key: String!) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
+
+# Add time.Time support
+scalar Time
+
+# Add time.Duration support
+scalar Duration
+
+# Add 'any' type for Event
+scalar Any
+
+"The repository's visibility level"
+enum RepositoryVisibility {
+ "The repository is visible only to those with explicit access"
+ PRIVATE
+ "The repository is visible to everyone"
+ PUBLIC
+ "The repository is visible only to users in the same business"
+ INTERNAL
+}
+
+"The possible reasons that an issue or Pull Request was locked"
+enum LockReason {
+ "The issue or Pull Request was locked because the conversation was off-topic"
+ OFF_TOPIC
+ "The issue or Pull Request was locked because the conversation was too heated"
+ TOO_HEATED
+ "The issue or Pull Request was locked because the conversation was resolved"
+ RESOLVED
+ "The issue or Pull Request was locked because the conversation was spam"
+ SPAM
+}
+
+"Detailed information about the current Pull Request merge state status"
+enum MergeStateStatus {
+ "The merge commit cannot be cleanly created"
+ DIRTY
+ "The state cannot currently be determined"
+ UNKNOWN
+ "The merge is blocked"
+ BLOCKED
+ "The head ref is out of date"
+ BEHIND
+ "Mergeable with non-passing commit status"
+ UNSTABLE
+ "Mergeable with passing commit status and pre-receive hooks"
+ HAS_HOOKS
+ "Mergeable and passing commit status"
+ CLEAN
+}
+
+"Whether or not a PullRequest can be merged"
+enum MergeableState {
+ "The Pull Request can be merged"
+ MERGEABLE
+ "The Pull Request cannot be merged due to merge conflicts"
+ CONFLICTING
+ "The mergeability of the Pull Request is still being calculated"
+ UNKNOWN
+}
+
+"The possible states of a Pull Request"
+enum PullRequestState {
+ "A Pull Request that is still open"
+ OPEN
+ "A Pull Request that has been closed without being merged"
+ CLOSED
+ "A Pull Request that has been closed by being merged"
+ MERGED
+}
+
+"The possible types of patch statuses"
+enum PatchStatus {
+ "The file was added. Git status 'A'"
+ ADDED
+ "The file was deleted. Git status 'D'"
+ DELETED
+ "The file was renamed. Git status 'R'"
+ RENAMED
+ "The file was copied. Git status 'C'"
+ COPIED
+ "The file's contents were changed. Git status 'M'"
+ MODIFIED
+ "The file's type was changed. Git status 'T'"
+ CHANGED
+}
+
+"The review status of a Pull Request"
+enum PullRequestReviewDecision {
+ "Changes have been requested on the Pull Request"
+ CHANGES_REQUESTED
+ "The Pull Request has received an approving review"
+ APPROVED
+ "A review is required before the Pull Request can be merged"
+ REVIEW_REQUIRED
+}
+
+type Context {
+ "The project the Pull Request belongs to"
+ Repository: ContextRepository!
+ @graphql(key: "repository(owner: $owner, name: $repo)")
+
+ "The project owner"
+ Owner: ContextUser! @graphql(key: "user(login: $owner)")
+
+ "Information about the Pull Request"
+ PullRequest: ContextPullRequest @generated
+
+ "Get information about current user"
+ Viewer: ContextUser!
+
+ "Information about the event that triggered the evaluation. Empty when not using webhook server."
+ WebhookEvent: Any @generated @expr(key: "webhook_event")
+}
+
+type ContextUser {
+ "The username used to login"
+ Login: String!
+}
+
+type PullRequestChangedFile {
+ "The number of additions to the file"
+ Additions: Int!
+ "How the file was changed in this PullRequest"
+ ChangeType: PatchStatus!
+ "The number of deletions to the file"
+ Deletions: Int!
+ "The path of the file"
+ Path: String!
+}
+
+"A list of nodes"
+type PullRequestChangedFileConnection {
+ Nodes: [PullRequestChangedFile!]
+}
+
+type GitActor {
+ "The timestamp of the Git action (authoring or committing)"
+ Date: Time
+ "The email in the Git commit"
+ Email: String
+ "The name in the Git commit"
+ Name: String
+ "The GitHub user corresponding to the email field. Null if no such user exists"
+ User: ContextUser
+}
+
+"Represents a Git commit"
+type ContextCommit {
+ "The number of additions in this commit"
+ Additions: Int!
+ "Authorship details of the commit"
+ Author: GitActor
+ "Check if the committer and the author match"
+ AuthoredByCommitter: Boolean!
+ "The datetime when this commit was authored"
+ AuthoredDate: Time!
+ "The number of changed files in this commit. If GitHub is unable to calculate the number of changed files (for example due to a timeout), this will return null. We recommend using this field instead of changedFiles"
+ ChangedFilesIfAvailable: Int
+ "The HTTP path for this Git object"
+ CommitResourcePath: String!
+ "The HTTP URL for this Git object"
+ CommitUrl: String!
+ "The datetime when this commit was committed"
+ CommittedDate: Time!
+ "Check if committed via GitHub web UI"
+ CommittedViaWeb: Boolean!
+ "Committer details of the commit"
+ Committer: GitActor
+ "The number of deletions in this commit"
+ Deletions: Int!
+ "The Git commit message"
+ Message: String!
+ "The Git commit message body"
+ MessageBody: String!
+ "The Git commit message headline"
+ MessageHeadline: String!
+ "The HTTP URL for this commit"
+ URL: String!
+}
+
+"Represents a Git commit part of a Pull Request"
+type PullRequestCommit {
+ "The Git commit object"
+ Commit: ContextCommit!
+}
+
+# Internal only, used to de-nest connections
+type ContextCommitsNode {
+ Nodes: [PullRequestCommit!] @internal
+}
+
+"Lookup a given repository by the owner and repository name"
+type ContextRepository {
+ "Whether or not a Pull Request head branch that is behind its base branch can always be updated even if it is not required to be up to date before merging"
+ AllowUpdateBranch: Boolean!
+ "Identifies the date and time when the repository was archived"
+ ArchivedAt: Time
+ "Whether or not Auto-merge can be enabled on Pull Requests in this repository"
+ AutoMergeAllowed: Boolean!
+ "Identifies the date and time when the object was created"
+ CreatedAt: Time!
+
+ # DefaultBranchRef:
+
+ "Whether or not branches are automatically deleted when merged in this repository"
+ DeleteBranchOnMerge: Boolean!
+ "The description of the repository"
+ Description: String
+ "Indicates if the repository has the Discussions feature enabled"
+ HasDiscussionsEnabled: Boolean!
+ "Indicates if the repository has issues feature enabled"
+ HasIssuesEnabled: Boolean!
+ "Indicates if the repository has the Projects feature enabled"
+ HasProjectsEnabled: Boolean!
+ "Indicates if the repository has wiki feature enabled"
+ HasWikiEnabled: Boolean!
+ "The Node ID of the Repository object"
+ ID: String!
+ "Indicates if the repository is unmaintained"
+ IsArchived: Boolean!
+ "Returns true if blank issue creation is allowed"
+ IsBlankIssuesEnabled: Boolean!
+ "Returns whether or not this repository disabled"
+ IsDisabled: Boolean!
+ "Identifies if the repository is a fork"
+ IsFork: Boolean!
+ "Indicates if the repository has been locked or not"
+ IsLocked: Boolean!
+ "Identifies if the repository is a mirror"
+ IsMirror: Boolean!
+ "Identifies if the repository is private or internal"
+ IsPrivate: Boolean!
+ "Identifies if the repository is a template that can be used to generate new repositories"
+ IsTemplate: Boolean!
+ "Is this repository a user configuration repository"
+ IsUserConfigurationRepository: Boolean!
+
+ # label(name: string)
+ # labels()
+ # latestRelease
+
+ "Whether or not PRs are merged with a merge commit on this repository"
+ MergeCommitAllowed: Boolean!
+ "The name of the repository"
+ Name: String!
+ "The repository's name with owner"
+ NameWithOwner: String!
+ "Identifies the date and time when the repository was last pushed to"
+ PushedAt: Time
+ "Whether or not rebase-merging is enabled on this repository"
+ RebaseMergeAllowed: Boolean!
+ "The HTTP path for this repository"
+ ResourcePath: String!
+ "Whether or not squash-merging is enabled on this repository"
+ SquashMergeAllowed: Boolean!
+ "Returns a count of how many stargazers there are on this object"
+ StargazerCount: Int!
+ "Identifies the date and time when the object was last updated"
+ UpdatedAt: Time!
+ "The HTTP URL for this repository"
+ URL: String!
+ "Indicates the repository's visibility level"
+ Visibility: RepositoryVisibility!
+
+ # Connections
+
+ PullRequest: ContextPullRequest @graphql(key: "pullRequest(number: $pr)") @internal
+}
+
+"A label for categorizing Issues, Pull Requests, Milestones, or Discussions with a given Repository"
+type ContextLabel {
+ "Identifies the label color"
+ Color: String!
+ "Identifies the date and time when the label was created"
+ CreatedAt: Time
+ "A brief description of this label"
+ Description: String
+ "The Node ID of the Label object"
+ ID: String!
+ "Indicates whether or not this is a default label"
+ IsDefault: Boolean!
+ "Identifies the label name"
+ Name: String!
+ "Identifies the date and time when the label was last updated"
+ UpdatedAt: Time!
+}
+
+# Internal only, used to de-nest connections
+type ContextLabelConnection {
+ Nodes: [ContextLabel!] @internal
+}
+
+"A repository Pull Request"
+type ContextPullRequest {
+ "Reason that the conversation was locked"
+ ActiveLockReason: LockReason
+ "The number of additions in this Pull Request"
+ Additions: Int!
+
+ # assignees()
+
+ "The actor who authored the comment"
+ Author: ContextUser
+
+ # autoMergeRequest()?
+ # baseRef()?
+
+ "Identifies the name of the base Ref associated with the Pull Request, even if the ref has been deleted"
+ BaseRefName: String!
+ "The body as Markdown"
+ Body: String!
+ "Whether or not the Pull Request is rebaseable"
+ CanBeRebased: Boolean!
+ "The number of changed files in this Pull Request"
+ ChangedFiles: Int!
+ "`true` if the Pull Request is closed"
+ Closed: Boolean!
+ "Identifies the date and time when the object was closed"
+ ClosedAt: Time
+
+ # comments()?
+ # commits()?
+
+ "Identifies the date and time when the object was created"
+ CreatedAt: Time!
+ "Check if this comment was created via an email reply"
+ CreatedViaEmail: Boolean!
+ "The number of deletions in this Pull Request"
+ Deletions: Int!
+
+ # editor: Actor ?? The actor who edited this Pull Request's body.
+ # headRef(?
+
+ "Identifies the name of the head Ref associated with the Pull Request, even if the ref has been deleted"
+ HeadRefName: String!
+
+ "The Node ID of the PullRequest object"
+ ID: String!
+ "Check if this comment was edited and includes an edit with the creation data"
+ IncludesCreatedEdit: Boolean!
+ "The head and base repositories are different"
+ IsCrossRepository: Boolean!
+ "Identifies if the Pull Request is a draft"
+ IsDraft: Boolean!
+ "Indicates whether the Pull Request is in a merge queue"
+ IsInMergeQueue: Boolean!
+ "Indicates whether the Pull Request's base ref has a merge queue enabled"
+ IsMergeQueueEnabled: Boolean!
+ "The moment the editor made the last edit"
+ LastEditedAt: Time
+ "`true` if the Pull Request is locked"
+ Locked: Boolean!
+ "Indicates whether maintainers can modify the Pull Request"
+ MaintainerCanModify: Boolean!
+
+ # mergeCommit: Commit?
+
+ "Detailed information about the current Pull Request merge state status"
+ MergeStateStatus: MergeStateStatus!
+ "Whether or not the Pull Request can be merged based on the existence of merge conflicts"
+ Mergeable: MergeableState!
+ "Whether or not the Pull Request was merged"
+ Merged: Boolean!
+ "The date and time that the Pull Request was merged"
+ MergedAt: Time
+ "The actor who merged the Pull Request"
+ mergedBy: ContextUser
+
+ # milestone: Milestone
+
+ "Identifies the Pull Request number"
+ Number: Int!
+ "The permalink to the Pull Request"
+ Permalink: String!
+ "Identifies when the comment was published at"
+ PublishedAt: Time
+ "The HTTP path for this Pull Request"
+ ResourcePath: String!
+ "The current status of this Pull Request with respect to code review"
+ ReviewDecision: PullRequestReviewDecision!
+ "Identifies the state of the Pull Request"
+ State: PullRequestState!
+ "Identifies the Pull Request title"
+ Title: String!
+ "Returns a count of how many comments this Pull Request has received"
+ TotalCommentsCount: Int
+ "Identifies the date and time when the object was last updated"
+ UpdatedAt: Time!
+ "The HTTP URL for this Pull Request"
+ URL: String!
+
+
+ #
+ # Connections
+ #
+
+ Files: [PullRequestChangedFile!] @generated
+ ResponseFiles: PullRequestChangedFileConnection
+ @graphql(key: "files(first:100)")
+ @internal
+
+ "Information about the first commit made"
+ FirstCommit: ContextCommit @generated()
+ "Information about the last commit made"
+ LastCommit: ContextCommit @generated()
+ "Duration between first and last commit made"
+ TimeBetweenFirstAndLastCommit: Duration @generated()
+ "Duration (from 'now') since the first commit was made"
+ TimeSinceFirstCommit: Duration @generated()
+ "Duration (from 'now') since the last commit was made"
+ TimeSinceLastCommit: Duration @generated()
+ "Labels available on this project"
+ Labels: [ContextLabel!] @generated
+
+ ResponseFirstCommits: ContextCommitsNode @internal @graphql(key: "first_commit: commits(first:1)")
+ ResponseLastCommits: ContextCommitsNode @internal @graphql(key: "last_commit: commits(last:1)")
+ ResponseLabels: ContextLabelConnection @internal @graphql(key: "labels(first:100)")
+}
diff --git a/schema/gitlab.go b/schema/main.go
similarity index 96%
rename from schema/gitlab.go
rename to schema/main.go
index 1352a1b..3ffd67b 100644
--- a/schema/gitlab.go
+++ b/schema/main.go
@@ -1,5 +1,4 @@
-//go:build ignore
-
+//nolint:dupl,varnamelen
package main
import (
@@ -31,9 +30,15 @@ var (
)
func main() {
+ process("github")
+ process("gitlab")
+}
+
+func process(scm string) {
+ Props = []*Property{}
PropMap = make(map[string]*Property)
- cfg, err := config.LoadConfig(getRootPath() + "/schema/gitlab.gqlgen.yml")
+ cfg, err := config.LoadConfig(getRootPath() + "/schema/" + scm + ".gqlgen.yml")
if err != nil {
fmt.Fprintln(os.Stderr, "failed to load config", err.Error())
@@ -54,14 +59,14 @@ func main() {
nest(Props)
- var index bytes.Buffer
tmpl := template.Must(template.New("index").Parse(docs))
+ var index bytes.Buffer
if err := tmpl.Execute(&index, Props[0]); err != nil {
panic(err)
}
- if err := os.WriteFile(getRootPath()+"/docs/gitlab/script-attributes.md", []byte(index.String()), 0600); err != nil {
+ if err := os.WriteFile(getRootPath()+"/docs/"+scm+"/script-attributes.md", []byte(index.String()), 0o600); err != nil {
panic(err)
}
@@ -214,6 +219,7 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild {
fieldProperty.IsEnum = true
fieldProperty.Enum = enum
}
+
fieldProperty.IsCustomType = !fieldProperty.IsEnum
fieldType = strcase.ToSnake(fieldType)