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: > + + + Created with Sketch. + + + + + + + + + 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)