diff --git a/flake.nix b/flake.nix index 14c4636..76033c7 100644 --- a/flake.nix +++ b/flake.nix @@ -106,6 +106,7 @@ specialArgs = { inherit inputs; + self = inputs.self; }; common_modules = [ { nixpkgs.pkgs = pkgs; } @@ -193,6 +194,8 @@ inherit system specialArgs; modules = [ { nixpkgs.pkgs = pkgs; } + ./system/marge-bot + ./system/renovate-bot ./hosts/stonehenge inputs.sops-nix.nixosModules.default ]; diff --git a/hosts/stonehenge/default.nix b/hosts/stonehenge/default.nix index c299dab..84ea856 100644 --- a/hosts/stonehenge/default.nix +++ b/hosts/stonehenge/default.nix @@ -12,6 +12,9 @@ ./nebula-vpn.nix ./vagrant.nix + ./gitlab-marge-bot.nix + ./renovate-bot.nix + ../../system/sops.nix ../../system/nix.nix ]; diff --git a/hosts/stonehenge/gitlab-marge-bot.nix b/hosts/stonehenge/gitlab-marge-bot.nix new file mode 100644 index 0000000..c7b49b0 --- /dev/null +++ b/hosts/stonehenge/gitlab-marge-bot.nix @@ -0,0 +1,41 @@ +{ + config, + self, + pkgs, + ... +}: + +let + s = config.sops.secrets; + cfg = config.services.marge-bot; + + secretConfig = { + owner = cfg.user; + group = cfg.group; + sopsFile = ../../secrets/stonehenge/default.yaml; + }; +in +{ + services.marge-bot = { + enable = true; + package = self.packages.${pkgs.system}.marge-bot; + gitlabUrl = "https://gitlab.wopus.dev"; + authTokenFile = s."gitlab-marge-bot/token".path; + sshKeyFile = s."gitlab-marge-bot/ssh-secret-key".path; + settings = { + ci-timeout = "60min"; + add-part-of = true; + add-reviewers = true; + keep-reviewers = true; + keep-commits = true; + impersonate-approvers = true; + + batch = true; + use-no-ff-batches = true; + skip-ci-batches = true; + }; + }; + + sops.secrets."gitlab-marge-bot/token" = secretConfig; + sops.secrets."gitlab-marge-bot/ssh-secret-key" = secretConfig; +} diff --git a/hosts/stonehenge/renovate-bot.nix b/hosts/stonehenge/renovate-bot.nix new file mode 100644 index 0000000..4b00939 --- /dev/null +++ b/hosts/stonehenge/renovate-bot.nix @@ -0,0 +1,49 @@ +{ config, pkgs, ... }: +let + cfg = config.services.renovate-bot; + s = config.sops.secrets; +in +{ + services.renovate-bot = { + enable = true; + schedule = "*-*-* *:00:00"; + logLevel = "info"; + + platform = "gitlab"; + endpoint = "https://gitlab.wopus.dev/api/v4"; + tokenFile = s."renovate-bot/token".path; + envFile = s."renovate-bot/env".path; + + extraPackages = with pkgs; [ + nodejs + rustc + cargo + php + phpPackages.composer + ]; + + settings = { + autodiscover = true; + labels = [ "renovate" ]; + rebaseWhen = "conflicted"; + + cacheDir = "/var/lib/renovate-bot/cache"; + persistRepoData = true; + prConcurrentLimit = 2; + branchConcurrentLimit = 2; + }; + }; + + sops.secrets."renovate-bot/token" = { + owner = cfg.user; + group = cfg.group; + mode = "0400"; + sopsFile = ../../secrets/stonehenge/default.yaml; + }; + sops.secrets."renovate-bot/env" = { + owner = cfg.user; + group = cfg.group; + mode = "0400"; + sopsFile = ../../secrets/stonehenge/default.yaml; + }; +} diff --git a/pkgs/default.nix b/pkgs/default.nix index f33aa3f..17a6fc7 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -7,6 +7,7 @@ rec { cargo-checkmate = pkgs.callPackage ./cargo-checkmate.nix { }; lipsum = pkgs.callPackage ./lipsum.nix { }; emmet-cli = pkgs.callPackage ./emmet-cli.nix { }; + marge-bot = pkgs.callPackage ./marge-bot { }; material-wifi-icons = pkgs.callPackage ./material-wifi-icons.nix { }; gnome-pass-search-provider = pkgs.callPackage ./gnome-pass-search-provider.nix { }; my-factorio-headless = pkgs.callPackage ./factorio-headless { diff --git a/pkgs/marge-bot/default.nix b/pkgs/marge-bot/default.nix new file mode 100644 index 0000000..b240c40 --- /dev/null +++ b/pkgs/marge-bot/default.nix @@ -0,0 +1,76 @@ +{ + lib, + python3, + fetchFromGitLab, + fetchpatch, + git, + openssh, + nix-update-script, +}: + +python3.pkgs.buildPythonApplication rec { + pname = "marge-bot"; + version = "0.16.0"; + pyproject = true; + + src = fetchFromGitLab { + owner = "marge-org"; + repo = "marge-bot"; + rev = version; + hash = "sha256-UgdbeJegeTFP6YF6oMxAeQDI9AO2k6yk4WAFZ/Xspu8="; + }; + + patches = [ + # TODO: remove when merged https://gitlab.com/marge-org/marge-bot/-/merge_requests/571 + (fetchpatch { + url = "https://gitlab.com/marge-org/marge-bot/-/commit/7e9668b24455bcf9c99646853019a3e86505d850.patch"; + hash = "sha256-n5zB3YmD7i6RmcO9XmHjWVdQHzeVuZUPOzY3+cA6NDk="; + }) + ./patches/wait-for-mr-update.patch + ./patches/allow-self-merges.patch + ]; + + nativeBuildInputs = [ + python3.pkgs.setuptools + ]; + + propagatedBuildInputs = + (with python3.pkgs; [ + configargparse + maya + pyyaml + requests + python-gitlab + hatchling + ]) + ++ [ + git + openssh + ]; + + nativeCheckInputs = + (with python3.pkgs; [ + pytest-cov-stub + pytestCheckHook + pendulum + ]) + ++ [ + git + ]; + + pythonImportsCheck = [ "marge" ]; + + passthru.updateScript = nix-update-script { }; + + meta = with lib; { + description = "Merge bot for GitLab"; + homepage = "https://gitlab.com/marge-org/marge-bot"; + changelog = "https://gitlab.com/marge-org/marge-bot/-/blob/${src.rev}/CHANGELOG.md"; + license = licenses.bsd3; + maintainers = with maintainers; [ + bcdarwin + lelgenio + ]; + mainProgram = "marge.app"; + }; +} diff --git a/pkgs/marge-bot/patches/allow-self-merges.patch b/pkgs/marge-bot/patches/allow-self-merges.patch new file mode 100644 index 0000000..5ea4904 --- /dev/null +++ b/pkgs/marge-bot/patches/allow-self-merges.patch @@ -0,0 +1,38 @@ +diff --git i/marge/job.py w/marge/job.py +index ae707c0..404fb18 100644 +--- i/marge/job.py ++++ w/marge/job.py +@@ -616,8 +616,6 @@ def _get_reviewer_names_and_emails( + self_reviewed = {commit["author_email"] for commit in commits} & { + user.email for user in users + } +- if self_reviewed and len(users) <= 1: +- raise CannotMerge("Commits require at least one independent reviewer.") + return [f"{user.name} <{user.email}>" for user in users] + + +diff --git i/tests/test_approvals.py w/tests/test_approvals.py +index a65ae95..ecb38a4 100644 +--- i/tests/test_approvals.py ++++ w/tests/test_approvals.py +@@ -168,20 +168,6 @@ class TestApprovals: + commits=[], approvals=self.approvals, api=self.api + ) == ["Administrator ", "Roger Ebert "] + +- @patch("marge.user.User.fetch_by_id") +- def test_approvals_fails_when_same_author(self, user_fetch_by_id): +- info = dict(INFO, approved_by=list(INFO["approved_by"])) +- del info["approved_by"][1] +- approvals = Approvals(self.api, info) +- user_fetch_by_id.side_effect = lambda id, _: marge.user.User( +- self.api, USERS[id] +- ) +- commits = [{"author_email": "root@localhost"}] +- with pytest.raises(CannotMerge): +- _get_reviewer_names_and_emails( +- commits=commits, approvals=approvals, api=self.api +- ) +- + @patch("marge.user.User.fetch_by_id") + def test_approvals_succeeds_with_independent_author(self, user_fetch_by_id): + user_fetch_by_id.side_effect = lambda id, _: marge.user.User( diff --git a/pkgs/marge-bot/patches/wait-for-mr-update.patch b/pkgs/marge-bot/patches/wait-for-mr-update.patch new file mode 100644 index 0000000..4f4227c --- /dev/null +++ b/pkgs/marge-bot/patches/wait-for-mr-update.patch @@ -0,0 +1,13 @@ +diff --git i/marge/batch_job.py w/marge/batch_job.py +index b6423d8..3db302f 100644 +--- i/marge/batch_job.py ++++ w/marge/batch_job.py +@@ -205,6 +205,8 @@ class BatchMergeJob(job.MergeJob): + # Rebase and apply the trailers + self.update_merge_request(merge_request, source_repo_url=source_repo_url) + ++ time.sleep(10) ++ + # This switches git to + final_sha = self.merge_batch( + merge_request.target_branch, diff --git a/secrets/stonehenge/default.yaml b/secrets/stonehenge/default.yaml index 2db1ef6..22e93c3 100644 --- a/secrets/stonehenge/default.yaml +++ b/secrets/stonehenge/default.yaml @@ -6,6 +6,12 @@ nebula-wopus-vpn: ca-crt: ENC[AES256_GCM,data:hV4V9wqOVUhkx6EtNOz1Dd+JzOuWFwwVwFAqkZIOdF4zIAOUvJHN2iUq1bMVLJOWpMcaxTTuXKXTKPbujs8K8TDzpRQzM22SD5o8aZAyPfif/GDUFFaLBygZropM7lUD9WDbjOucCRBKoj9cbazLsabixF1gVR/lZxyPBaquoIlBWvUiFbF5P3CLQGZ5ENprHvHRuFPciiw0JqJJNme/gaz2CBXRbEYxjVFCjwFEYQrxcMxhRw+p/eHCVzUmnOBo+09HFYpBZvIY5Q8F+MPxstWIaeEzn3Spfiw9lRGw7/r6V+Vd8ppKcKWQfgVYynY=,iv:CQjMsZc4oFP4ZDifvynVrh0w1zvXX+g93HOOsdEV2WE=,tag:gRSKJbgkzyLJyHhRqVBL9A==,type:str] stonehenge-crt: ENC[AES256_GCM,data:y1FQvKI3AOvp8K04qghseuhvaL/yYfjl1lTX2z0f1u61VfLMOPj7R0jR48D5bHXfrTD6exxny6wEy3wuWP105rkLD8oxehzNuT2jgUu85OB3w3yZHdPmW+8lftZcd21BwO0uPTab8EOB19wOCMYuGnO7JL/IRwPTFXVOmKx99+jD5mh5370yB05VVMflSlmA4iCbCvvhTmB1eHFc9a5g687Rwi5PlPEhaaEUDnjyZByO7Uu1nrBBtd5koQIDshIhuQKsVeB4AIOF6EER8dYlLSu9G6GS1cVKuaNoMiUfXLn0Y9kdDDRqetuCteGEd8euwUWGq5XVFIhlOfU6cZOR/wUskrUYWQ+3MApk6TJQQd9HBSU9SoARJZXPXX/RgCIFczeW/dIc1oPRfagnKECS4g==,iv:HSIcmYJib6SsuTbDV4zFePBryCIy0nzV8O5NSAjwuQs=,tag:bonhzMDsyvC/Gn5HLHrJkQ==,type:str] stonehenge-key: ENC[AES256_GCM,data:HstlV1VXX6edP5XrPUanUfO8yK20imHXwYsV/q/W4IyA+yEH9inYt4oiw3cIvGawx7gfvOpsqU4IUxLsNr4EE83qg3YqkMrnGjYuHTe1LfGsktGhibbCqw4+kcqb12bywuXmPLb9EI4KBCzUi7EQTh4sLEGsqiujS0aUC4qutQ==,iv:RKT2ZM1NeA4MmfbyVvIQ96lNvErSydF8668oHyo4LHg=,tag:EhZlHF7PdAQ0whu/JxIbWw==,type:str] +gitlab-marge-bot: + token: ENC[AES256_GCM,data:sdTkMT1o6XfyICqf7KWGs0s7YP8o8GEYrF4=,iv:uYbuKsJ9YpdzWC7c1awGf8a43bleD6vpl84UO+CMhzA=,tag:7eOhPtOAQnKAFZjwWV5AIg==,type:str] + ssh-secret-key: ENC[AES256_GCM,data:SFM+IRAl1OFJU2QLVHcaBrX6qmrprRNT45aQYnTauiRW1EQ0hZrR9VhDZi9DNNdbTE1pPiGQnT7sKdYbegbBkvF6YtvQIO/3BIGUmmHMACw3MfjlQmOqHr5pjxdQQSneHCN4448tvY9Gd2Q04pZC8tvGoYw5DSmHVXnb6YN9UsX5uCTobxg54RkHByTAGF2++r27RLD/96crVqWBQwRltnpz2hK47Jtp+Dy9weALA+gBppTsKb4e9uaEYaZ9M2fRZqT6qFggDf+orSBI1uBqrglYk7iM0NOsB3Tvk8P5emOT7LxyPce5ZEhvnTOJEnC+YyPeMPLGeICdu+1YTmsOB9ORwKm5IFjTZ6SlBUofTN8c+GJkiReaxcgyf5nSQKK+EgbdrKch+c0okZo4zNiWcDk5MAuNF3tGl4lS/coOwMoVX2jMfUmfQqeidR3spTb9lMlbBe5EXLtgWgRUvQQT/oEhwuLjisUrbTTmjHufwUNDI2yVmCFS0r0u6DadYwWIS/p5wTbQBMzxaznBoA0e,iv:C7pa3z4d69juElVV6vwxQ+hxkUFIv/0efpOx4bgN1I0=,tag:A8J69d5i1qN2tEdn7tikuQ==,type:str] +renovate-bot: + token: ENC[AES256_GCM,data:oVWQKzIFTxtRbCs1Lv/Xewg9TkWTkYqwOFq7PTtaRSAm52GMbM/VFdRdLTvtACamoaDp,iv:loVzsYyzv4GI9IB/n1v/OOYenq1TXjuC1TNZfyu1nRk=,tag:LZGeAWIEkcy+JV97vIA0wg==,type:str] + env: ENC[AES256_GCM,data:5ELu20a39gFQQ+tKS5Jtzy7zxtKsrZ6QbvbSeSdSPUx+XYHOCpOe8CuswI6A+2wKP8D8X41gebOKXsFdoxsLrcpoVg==,iv:ReemN9DtvDR5Ue9quO52oJrFME8x7GxILbf9H5t7dmg=,tag:3T+RTz1PIQRcAkrnl9gAOQ==,type:str] sops: age: - recipient: age1zrgu7w8059xydagm60phnffghvfe9h2ca58cx8qwagqpyfuvs9fqw79c8h @@ -26,8 +32,8 @@ sops: dC9MaDUvcG96djVFU1Fpb1NKZThNaUEKkxPikf5+veTmrXHU4sxtJO/LsQ3YB4j+ vkIWWw4qV8zRrh+XxFXrFUURhDp11m/nlpzPERxjNzRs13VS2tXTrw== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-01-01T22:54:16Z" - mac: ENC[AES256_GCM,data:OF2RLQTbuiW3ba9VBhmJCq3UUlVACe/lxhY9RAjctaZBXTutjH84JuYG9idXiJkZkkG5l9OIez3WueLsU44RG1UgkbHAM5d6RrXsvsleVux0hViH0CIAB4K7NaeA+urgM3TQbXlBVgY2w18bA/BpcbxH3HiMC+9/iOWWJMBZ0RM=,iv:MtRBqhc71fzjLXE8S54woNnCL+0iqFhQ28N+Zz9RSyM=,tag:Aa+wJcyaTjamZ0fA2P9oQg==,type:str] + lastmodified: "2026-02-13T22:27:32Z" + mac: ENC[AES256_GCM,data:5HPABMfLg3ZGpuAH22u7PK+aeLhCfG+hhQwCVvuInk5KLabNne0tDp11I4CscPOx2235uqWUVH21vor8pcITEIFv9ZkJTvrT4Jmg1elXR1Pwv++2Eq61XgJC9LkQe42s59WXtKSg0jNfbT2HjUXdYEhnc6efTNiNivv4ekkPHJA=,iv:zf6PpnQ7L/bQfD1OHoXgsB0HCHyVxu+pbNmBBbZuh7k=,tag:JYH8OVI/SbZcufl4gQP3dg==,type:str] pgp: - created_at: "2026-01-01T21:36:47Z" enc: |- diff --git a/system/marge-bot/default.nix b/system/marge-bot/default.nix new file mode 100644 index 0000000..4a5f1d6 --- /dev/null +++ b/system/marge-bot/default.nix @@ -0,0 +1,374 @@ +# TODO: +# - [ ] Check that types.oneOf is the correct way to define options with multiple values + +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.marge-bot; + inherit (lib) + mkOption + mkEnableOption + types + ; + + margeBotConfig = cfg.settings // { + gitlab-url = cfg.gitlabUrl; + auth-token-file = cfg.authTokenFile; + ssh-key-file = cfg.sshKeyFile; + }; + + margeBotConfigFile = pkgs.writeText "marge-bot-config.yaml" ( + pkgs.lib.generators.toYAML { } margeBotConfig + ); +in +{ + options = { + services.marge-bot = { + enable = mkEnableOption "GitLab Marge Bot service"; + + package = mkOption { + type = types.package; + default = pkgs.marge-bot; + defaultText = "pkgs.marge-bot"; + description = "Marge Bot package to use"; + }; + + gitlabUrl = mkOption { + type = types.str; + default = "https://gitlab.com"; + description = "GitLab instance URL"; + example = "https://gitlab.example.com"; + }; + + authTokenFile = mkOption { + type = types.str; + description = "Path to file containing GitLab authentication token"; + }; + + sshKeyFile = mkOption { + type = types.str; + description = "Path to SSH private key file for Git operations"; + }; + + user = mkOption { + type = types.str; + default = "gitlab-marge-bot"; + description = "User account under which marge-bot runs"; + }; + + group = mkOption { + type = types.str; + default = "gitlab-marge-bot"; + description = "Group account under which marge-bot runs"; + }; + + homeDirectory = mkOption { + type = types.str; + default = "/var/lib/marge-bot"; + description = "Home directory for the marge-bot user"; + }; + + settings = mkOption { + default = { }; + type = types.submodule { + freeformType = + with types; + attrsOf (oneOf [ + str + bool + int + float + (listOf str) + ]); + + options = { + # Based on https://marge-bot.readthedocs.io/en/latest/configuration + + use-https = mkOption { + type = types.bool; + default = false; + description = "Use HTTP(S) instead of SSH for GIT repository access"; + }; + + embargo = mkOption { + type = types.nullOr types.str; + default = null; + example = "Friday 1pm - Monday 9am"; + description = '' + Time(s) during which no merging is to take place. + Example: "Friday 1pm - Monday 9am" + ''; + }; + + use-merge-strategy = mkOption { + type = types.bool; + default = false; + description = '' + Use git merge instead of git rebase to update the source branch (EXPERIMENTAL). + If you need to use a strict no-rebase workflow. + ''; + }; + + rebase-remotely = mkOption { + type = types.bool; + default = false; + description = '' + Instead of rebasing in a local clone of the repository, use GitLab's + built-in rebase functionality, via their API. Note that Marge can't add + information in the commits in this case. + ''; + }; + + add-tested = mkOption { + type = types.bool; + default = false; + description = '' + Add "Tested: marge-bot <$MR_URL>" for the final commit on branch after it passed CI. + ''; + }; + + batch = mkOption { + type = types.bool; + default = false; + description = "Enable processing MRs in batches"; + }; + + add-part-of = mkOption { + type = types.bool; + default = false; + description = '' + Add "Part-of: <$MR_URL>" to each commit in MR. + ''; + }; + + batch-branch-name = mkOption { + type = types.str; + default = "marge_bot_batch_merge_job"; + description = "Branch name when batching is enabled"; + }; + + add-reviewers = mkOption { + type = types.bool; + default = false; + description = '' + Add "Reviewed-by: $approver" for each approver of MR to each commit in MR. + ''; + }; + + keep-committers = mkOption { + type = types.bool; + default = false; + description = "Keep the original commit info during rebases"; + }; + + keep-reviewers = mkOption { + type = types.bool; + default = false; + description = '' + Ensure previous "Reviewed-by: $approver" aren't dropped by --add-reviewers + ''; + }; + + impersonate-approvers = mkOption { + type = types.bool; + default = false; + description = "Marge-bot pushes effectively don't change approval status"; + }; + + merge-order = mkOption { + type = types.enum [ + "created_at" + "updated_at" + "assigned_at" + ]; + default = "created_at"; + description = '' + Order marge merges assigned requests. + Options: created_at (default), updated_at or assigned_at. + ''; + }; + + approval-reset-timeout = mkOption { + type = types.str; + default = "0s"; + description = '' + How long to wait for approvals to reset after pushing. + Only useful with the "new commits remove all approvals" option in a project's settings. + This is to handle the potential race condition where approvals don't reset in GitLab + after a force push due to slow processing of the event. + ''; + }; + + project-regexp = mkOption { + type = types.str; + default = ".*"; + example = "some_group/.*"; + description = '' + Only process projects that match; e.g. 'some_group/.*' or '(?!exclude/me)'. + ''; + }; + + ci-timeout = mkOption { + type = types.str; + default = "15min"; + description = "How long to wait for CI to pass"; + }; + + git-timeout = mkOption { + type = types.str; + default = "120s"; + description = "How long a single git operation can take"; + }; + + git-reference-repo = mkOption { + type = types.nullOr types.str; + default = null; + description = "A reference repo to be used when git cloning"; + }; + + branch-regexp = mkOption { + type = types.str; + default = ".*"; + description = '' + Only process MRs whose target branches match the given regular expression. + ''; + }; + + source-branch-regexp = mkOption { + type = types.str; + default = ".*"; + description = '' + Only process MRs whose source branches match the given regular expression. + ''; + }; + + debug = mkOption { + type = types.bool; + default = false; + description = "Debug logging (includes all HTTP requests etc)"; + }; + + run-manual-jobs = mkOption { + type = types.bool; + default = false; + description = "Add this flag to have Marge run on manual jobs within the pipeline"; + }; + + use-no-ff-batches = mkOption { + type = types.bool; + default = false; + description = "Disable fast forwarding when merging MR batches"; + }; + + use-merge-commit-batches = mkOption { + type = types.bool; + default = false; + description = '' + Use merge commit when creating batches, so that the commits in the batch MR + will be the same with in individual MRs. Requires sudo scope in the access token. + ''; + }; + + skip-ci-batches = mkOption { + type = types.bool; + default = false; + description = "Skip CI when updating individual MRs when using batches"; + }; + + cli = mkOption { + type = types.bool; + default = false; + description = "Run marge-bot as a single CLI command, not a service"; + }; + + guarantee-final-pipeline = mkOption { + type = types.bool; + default = false; + description = "Guaranteed final pipeline when assigned to marge-bot"; + }; + + exc-comment = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Provide additional text, like a log URL, to append to some exception-related MR comments. + ''; + }; + + custom-approver = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Specify one or more approver usernames to accept instead of asking GitLab. + For CE approval use. + ''; + }; + + custom-approvals-required = mkOption { + type = types.int; + default = 0; + description = '' + Required number of approvals from --custom-approval. + For CE approval use. + ''; + }; + + hooks-directory = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to the directory where your custom hooks are located"; + }; + }; + }; + description = '' + Settings for the marge-bot configuration. + See https://marge-bot.readthedocs.io/en/latest/configuration + for detailed information about all available settings. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + + warnings = + (lib.optional (lib.isStorePath cfg.authTokenFile) '' + services.marge-bot.authTokenFile points to a file in the Nix store. + You should use a quoted absolute path instead. + '') + ++ (lib.optional (lib.isStorePath cfg.sshKeyFile) '' + services.marge-bot.sshKeyFile points to a file in the Nix store. + You should use a quoted absolute path instead. + ''); + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.homeDirectory; + createHome = true; + }; + + users.groups.${cfg.group} = { }; + + systemd.services.gitlab-marge-bot = { + description = "GitLab Marge Bot Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.homeDirectory; + ExecStart = "${lib.getExe cfg.package} --config-file ${margeBotConfigFile}"; + Restart = "on-failure"; + RestartSec = "10s"; + }; + }; + + }; +} diff --git a/system/renovate-bot/default.nix b/system/renovate-bot/default.nix new file mode 100644 index 0000000..db954f7 --- /dev/null +++ b/system/renovate-bot/default.nix @@ -0,0 +1,288 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.renovate-bot; + inherit (lib) + mkOption + mkEnableOption + types + ; + + renovateConfig = cfg.settings // { + platform = cfg.platform; + endpoint = cfg.endpoint; + }; + + renovateConfigFile = pkgs.writeText "renovate-config.json" (builtins.toJSON renovateConfig); + + renovateWrapper = pkgs.writeShellScript "renovate-wrapper" '' + export RENOVATE_TOKEN=$(cat "${cfg.tokenFile}") + exec ${lib.getExe cfg.package} ${lib.concatStringsSep " " cfg.repositories} + ''; +in +{ + options = { + services.renovate-bot = { + enable = mkEnableOption "Renovate Bot service"; + + package = mkOption { + type = types.package; + default = pkgs.renovate; + defaultText = "pkgs.renovate"; + description = "Renovate Bot package to use"; + }; + + platform = mkOption { + type = types.enum [ + "gitlab" + "github" + "bitbucket" + "azure" + ]; + default = "gitlab"; + description = "Git platform to use"; + }; + + endpoint = mkOption { + type = types.str; + default = "https://gitlab.com"; + description = "Git platform endpoint URL"; + example = "https://gitlab.example.com"; + }; + + tokenFile = mkOption { + type = types.str; + description = "Path to file containing authentication token"; + }; + + envFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to file containing environment variables (in .env format)"; + }; + + user = mkOption { + type = types.str; + default = "renovate-bot"; + description = "User account under which renovate-bot runs"; + }; + + group = mkOption { + type = types.str; + default = "renovate-bot"; + description = "Group account under which renovate-bot runs"; + }; + + homeDirectory = mkOption { + type = types.str; + default = "/var/lib/renovate-bot"; + description = "Home directory for the renovate-bot user"; + }; + + repositories = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "List of repositories to monitor (format: owner/repo)"; + example = [ + "myorg/myrepo" + "myorg/another-repo" + ]; + }; + + schedule = mkOption { + type = types.nullOr types.str; + default = null; + example = "before 6am"; + description = "Schedule for Renovate runs (systemd timer OnCalendar format)"; + }; + + logLevel = mkOption { + type = types.enum [ + "fatal" + "error" + "warn" + "info" + "debug" + "trace" + ]; + default = "info"; + description = "Log level for Renovate (set via LOG_LEVEL environment variable)"; + }; + + extraPackages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = "Extra packages to add to PATH for the Renovate Bot service"; + example = "[ pkgs.curl pkgs.jq ]"; + }; + + nodeMemoryLimit = mkOption { + type = types.int; + default = 4096; + description = "Node.js memory limit in MB (--max-old-space-size)"; + }; + + settings = mkOption { + default = { }; + type = types.submodule { + freeformType = + with types; + attrsOf (oneOf [ + str + bool + int + float + (listOf str) + (attrsOf anything) + ]); + + options = { + # Common Renovate configuration options + # Based on https://docs.renovatebot.com/configuration-options/ + + requireConfig = mkOption { + type = types.bool; + default = false; + description = "Require renovate.json config file"; + }; + + dryRun = mkOption { + type = types.bool; + default = false; + description = "Run in dry-run mode (no PRs created)"; + }; + + printConfig = mkOption { + type = types.bool; + default = false; + description = "Print the resolved config and exit"; + }; + + gitAuthor = mkOption { + type = types.nullOr types.str; + default = null; + example = "Renovate Bot "; + description = "Git author for commits"; + }; + + gitPrivateKey = mkOption { + type = types.nullOr types.str; + default = null; + description = "Private key for git operations"; + }; + + includeForks = mkOption { + type = types.bool; + default = false; + description = "Include forked repositories"; + }; + + includeMirrors = mkOption { + type = types.bool; + default = false; + description = "Include mirrored repositories"; + }; + + autodiscover = mkOption { + type = types.bool; + default = false; + description = "Auto-discover repositories"; + }; + + autodiscoverFilter = mkOption { + type = types.nullOr types.str; + default = null; + description = "Filter for auto-discovered repositories"; + }; + + timezone = mkOption { + type = types.nullOr types.str; + default = null; + example = "America/Sao_Paulo"; + description = "Timezone for scheduling"; + }; + + prConcurrentLimit = mkOption { + type = types.int; + default = 10; + description = "Maximum number of concurrent PRs"; + }; + + prHourlyLimit = mkOption { + type = types.int; + default = 2; + description = "Maximum number of PRs per hour (0 = unlimited)"; + }; + + branchConcurrentLimit = mkOption { + type = types.int; + default = 10; + description = "Maximum number of concurrent branches (0 = unlimited)"; + }; + }; + }; + description = '' + Settings for the Renovate configuration. + See https://docs.renovatebot.com/configuration-options/ + for detailed information about all available settings. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + + warnings = ( + lib.optional (lib.isStorePath cfg.tokenFile) '' + services.renovate-bot.tokenFile points to a file in the Nix store. + You should use a quoted absolute path instead. + '' + ); + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.homeDirectory; + createHome = true; + }; + + users.groups.${cfg.group} = { }; + + systemd.services.renovate-bot = { + description = "Renovate Bot Service"; + after = [ "network.target" ]; + + path = [ pkgs.git ] ++ cfg.extraPackages; + + environment = { + RENOVATE_CONFIG_FILE = renovateConfigFile; + LOG_LEVEL = cfg.logLevel; + NODE_OPTIONS = "--max-old-space-size=${toString cfg.nodeMemoryLimit}"; + }; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.homeDirectory; + ExecStart = "${renovateWrapper}"; + } + // lib.optionalAttrs (cfg.envFile != null) { + EnvironmentFile = cfg.envFile; + }; + }; + + systemd.timers.renovate-bot = lib.mkIf (cfg.schedule != null) { + description = "Renovate Bot Timer"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.schedule; + Persistent = true; + }; + }; + }; +}