From df7e14113336930d2d1566bb596050812cfecbf0 Mon Sep 17 00:00:00 2001 From: Tommy Skaug Date: Tue, 12 Nov 2024 10:12:42 +0100 Subject: [PATCH] chore: more restructuring of the repo. Most significant change is rename from flakes-action to ci-os --- .gitignore | 2 + .taskfiles/docker.yaml | 6 +- apps/{lix-builder => ci-os}/README.md | 31 +- apps/{lix-builder => ci-os}/ci/goss.yaml | 0 apps/{lix-builder => ci-os}/flake.lock | 0 apps/{lix-builder => ci-os}/flake.nix | 21 +- .../packages/default.nix | 1 + .../packages/flux-diff/default.nix | 0 .../packages/flux-local/default.nix | 0 .../packages/forgejo-comment/default.nix | 13 + .../packages/forgejo-comment/src/go.mod | 0 .../packages/forgejo-comment/src/main.go | 142 +++ apps/ci-os/packages/forgejo-release/README.md | 0 .../packages/forgejo-release/default.nix | 13 + .../ci-os/packages/forgejo-release/src/go.mod | 3 + .../packages/forgejo-release/src/main.go | 826 ++++++++++++++++++ apps/lix-builder/manifest.yaml | 102 --- apps/lix-builder/metadata.yaml | 9 - .../packages/forgejo-comment/default.nix | 20 - .../packages/forgejo-comment/src/main.go | 142 --- 20 files changed, 1035 insertions(+), 296 deletions(-) rename apps/{lix-builder => ci-os}/README.md (71%) rename apps/{lix-builder => ci-os}/ci/goss.yaml (100%) rename apps/{lix-builder => ci-os}/flake.lock (100%) rename apps/{lix-builder => ci-os}/flake.nix (83%) rename apps/{lix-builder => ci-os}/packages/default.nix (78%) rename apps/{lix-builder => ci-os}/packages/flux-diff/default.nix (100%) rename apps/{lix-builder => ci-os}/packages/flux-local/default.nix (100%) create mode 100644 apps/ci-os/packages/forgejo-comment/default.nix rename apps/{lix-builder => ci-os}/packages/forgejo-comment/src/go.mod (100%) create mode 100644 apps/ci-os/packages/forgejo-comment/src/main.go create mode 100644 apps/ci-os/packages/forgejo-release/README.md create mode 100644 apps/ci-os/packages/forgejo-release/default.nix create mode 100644 apps/ci-os/packages/forgejo-release/src/go.mod create mode 100644 apps/ci-os/packages/forgejo-release/src/main.go delete mode 100644 apps/lix-builder/manifest.yaml delete mode 100644 apps/lix-builder/metadata.yaml delete mode 100644 apps/lix-builder/packages/forgejo-comment/default.nix delete mode 100644 apps/lix-builder/packages/forgejo-comment/src/main.go diff --git a/.gitignore b/.gitignore index 28e305e..59e1d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ secrets.env apps/lix-builder/result +apps/ci-os/dist/test +apps/ci-os/result diff --git a/.taskfiles/docker.yaml b/.taskfiles/docker.yaml index 54a0877..30d431b 100644 --- a/.taskfiles/docker.yaml +++ b/.taskfiles/docker.yaml @@ -1,12 +1,12 @@ version: "3" tasks: - build-flakesaction: + build-ci-os: desc: Builds and pushes the flakes action image dir: "{{ .ROOT_DIR }}/apps/lix-builder" cmds: - - nix build .#packages.x86_64-linux.flakes-action && nerdctl load < result - && nerdctl push code.252.no/tommy/flakes-action:latest + - nix build .#packages.x86_64-linux.build-image && nerdctl load < result && + nerdctl push code.252.no/tommy/ci-os:latest create-image: desc: Build local docker image (nixos-builder) diff --git a/apps/lix-builder/README.md b/apps/ci-os/README.md similarity index 71% rename from apps/lix-builder/README.md rename to apps/ci-os/README.md index fadd356..60c2d87 100644 --- a/apps/lix-builder/README.md +++ b/apps/ci-os/README.md @@ -1,4 +1,4 @@ -## Flakes Action +## CI-OS This container is a little special since it provides a Nix flake designed to generate a docker image to use with [Forgejo runners](https://code.forgejo.org/forgejo/runner). It packages essential tools and helper programs to streamline @@ -14,30 +14,31 @@ We try to reproduce much-used GitHub actions into one package to avoid calling a ### Docker Image Info -The latest container resulting from the nix build, is located in the registry at `code.252.no/tommy/flakes-action:latest` and includes: +The latest container resulting from the nix build, is located in the registry at `code.252.no/tommy/ci-os:latest` and includes: - **Nix Environment**: Pre-configured with Nix and essential configurations. -- **Helper Programs**: Bundles `flux-local`, `flux-diff`, and `forgejo-comment`. +- **Helper Programs**: Bundles `flux-local`, `flux-diff`, `forgejo-comment` and more. - **Essential Build Tools**: Includes utilities like `git`, `docker`, `bash`, `curl`, `jq`, and more. ### Nix Flake Info - **Apps**: Accessible via `nix run` or `nix shell` commands. -- **Packages**: Builds the `flakes-action` Docker image named `flakes-action` with necessary tools. +- **Packages**: Builds the `ci-os` Docker image named `ci-os` with necessary tools. ### Helper Programs Provided The flake provides the following applications: -| Application | Description | External Ref | -|-------------------|-----------------------------------------------------------------|---------------------------------------------------------------------| -| `git-sv` | Semantic versioning tool for git based on conventional commits. | [tommy/git-sv](https://code.252.no/tommy/git-sv) -| `flux-local` | Tool for performing local Flux operations and diffs. | [allenporter/flux-local](https://github.com/allenporter/flux-local) | -| `flux-diff` | Utility to diff Flux resources locally. | [buroa/k8s-gitops](https://github.com/buroa/k8s-gitops/blob/master/.github/workflows/flux-diff.yaml) -| `forgejo-comment` | Script to post comments on Forgejo merge requests. | - +| Application | Description | External Ref | +|-------------------|-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------| +| `git-sv` | Semantic versioning tool for git based on conventional commits. | [tommy/git-sv](https://code.252.no/tommy/git-sv) | +| `flux-local` | Tool for performing local Flux operations and diffs. | [allenporter/flux-local](https://github.com/allenporter/flux-local) | +| `flux-diff` | Utility to diff Flux resources locally. | [buroa/k8s-gitops](https://github.com/buroa/k8s-gitops/blob/master/.github/workflows/flux-diff.yaml) | +| `forgejo-comment` | Script to post comments on Forgejo merge requests. | - | +| `forgejo-release` | Script to create releases in Forgejo. | - | -Other standard packages are provided from nixpkgs. For an up-to-date list have a look at `flakes-action` in +Other standard packages are provided from nixpkgs. For an up-to-date list have a look at `ci-os` in [flake.nix](./flake.nix). @@ -45,13 +46,13 @@ Other standard packages are provided from nixpkgs. For an up-to-date list have a #### Building -We provide an example taskfile in `task docker:build-flakesaction` which is used for manual builds at `code.252.no`. The task uses +We provide an example taskfile in `task docker:build-ci-os` which is used for manual builds at `code.252.no`. The task uses `nerdctl`, but you may replace this with `docker` or `podman`. ```bash -nix build .#packages.x86_64-linux.flakes-action # build image +nix build .#packages.x86_64-linux.ci-os # build image nerdctl load < result # loads nix build result (tar archive) -nerdctl push code.252.no/tommy/flakes-action:latest # push to registry +nerdctl push code.252.no/tommy/ci-os:latest # push to registry ``` #### In Runner @@ -82,7 +83,7 @@ To use the Docker image in your Forgejo runner add it to your Helm values (this - "--instance" - $(FORGEJO_INSTANCE_URL) - "--labels" - - "flakes-action:docker://code.252.no/tommy/flakes-action:latest,[...]" + - "ci-os:docker://code.252.no/tommy/ci-os:latest,[...]" env: - name: RUNNER_TOKEN valueFrom: diff --git a/apps/lix-builder/ci/goss.yaml b/apps/ci-os/ci/goss.yaml similarity index 100% rename from apps/lix-builder/ci/goss.yaml rename to apps/ci-os/ci/goss.yaml diff --git a/apps/lix-builder/flake.lock b/apps/ci-os/flake.lock similarity index 100% rename from apps/lix-builder/flake.lock rename to apps/ci-os/flake.lock diff --git a/apps/lix-builder/flake.nix b/apps/ci-os/flake.nix similarity index 83% rename from apps/lix-builder/flake.nix rename to apps/ci-os/flake.nix index 5399fcc..971b8ea 100644 --- a/apps/lix-builder/flake.nix +++ b/apps/ci-os/flake.nix @@ -1,5 +1,5 @@ { - description = "docker base images"; + description = "CI-OS - the Continuous Integration OS"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; @@ -25,23 +25,34 @@ flux-local = flake-utils.lib.mkApp { drv = pkgs-local.flux-local; }; flux-diff = flake-utils.lib.mkApp { drv = pkgs-local.flux-diff; }; forgejo-comment = flake-utils.lib.mkApp { drv = pkgs-local.forgejo-comment; }; + forgejo-release = flake-utils.lib.mkApp { drv = pkgs-local.forgejo-release; }; }; packages = { - flakes-action = pkgs.dockerTools.buildImageWithNixDb { - name = "code.252.no/tommy/flakes-action"; + build-image = pkgs.dockerTools.buildImageWithNixDb { + name = "code.252.no/tommy/ci-os"; tag = "latest"; copyToRoot = pkgs.buildEnv { name = "image-root"; pathsToLink = ["/bin" "/etc"]; ignoreCollisions = true; paths = with pkgs; [ - gitSvPkg + # kubernetes + chart-testing + kubernetes-helm + kubernetes-polaris + pluto pkgs-local.flux-local + pkgs-local.flux-diff + pkgs-local.forgejo-comment + pkgs-local.forgejo-release - findutils + # repository tooling + gitSvPkg + gnupg coreutils-full + findutils python312Full docker bash diff --git a/apps/lix-builder/packages/default.nix b/apps/ci-os/packages/default.nix similarity index 78% rename from apps/lix-builder/packages/default.nix rename to apps/ci-os/packages/default.nix index 22c650d..66d7f03 100644 --- a/apps/lix-builder/packages/default.nix +++ b/apps/ci-os/packages/default.nix @@ -7,4 +7,5 @@ in flux-local = fluxLocal; flux-diff = import ./flux-diff { inherit pkgs lib fluxLocal; }; forgejo-comment = import ./forgejo-comment { inherit lib pkgs; }; + forgejo-release = import ./forgejo-release { inherit lib pkgs; }; } \ No newline at end of file diff --git a/apps/lix-builder/packages/flux-diff/default.nix b/apps/ci-os/packages/flux-diff/default.nix similarity index 100% rename from apps/lix-builder/packages/flux-diff/default.nix rename to apps/ci-os/packages/flux-diff/default.nix diff --git a/apps/lix-builder/packages/flux-local/default.nix b/apps/ci-os/packages/flux-local/default.nix similarity index 100% rename from apps/lix-builder/packages/flux-local/default.nix rename to apps/ci-os/packages/flux-local/default.nix diff --git a/apps/ci-os/packages/forgejo-comment/default.nix b/apps/ci-os/packages/forgejo-comment/default.nix new file mode 100644 index 0000000..f79ec7c --- /dev/null +++ b/apps/ci-os/packages/forgejo-comment/default.nix @@ -0,0 +1,13 @@ +{ lib, pkgs }: + +pkgs.buildGoModule rec { + pname = "forgejo-comment"; + version = "1.0.0"; + + src = ./src; + + # Vendor dependencies for reproducibility + vendorHash = null; + + subPackages = [ "." ]; +} \ No newline at end of file diff --git a/apps/lix-builder/packages/forgejo-comment/src/go.mod b/apps/ci-os/packages/forgejo-comment/src/go.mod similarity index 100% rename from apps/lix-builder/packages/forgejo-comment/src/go.mod rename to apps/ci-os/packages/forgejo-comment/src/go.mod diff --git a/apps/ci-os/packages/forgejo-comment/src/main.go b/apps/ci-os/packages/forgejo-comment/src/main.go new file mode 100644 index 0000000..3253348 --- /dev/null +++ b/apps/ci-os/packages/forgejo-comment/src/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strconv" +) + +func main() { + // Define command-line flags + var forgejoAPIURL string + var token string + var repoOwner string + var repoName string + var issueId int + + flag.StringVar(&forgejoAPIURL, "forgejo-api-url", "https://code.252.no/api/v1", "Forgejo API URL") + flag.StringVar(&token, "token", "", "Forgejo API token") + flag.StringVar(&repoOwner, "repo-owner", "", "Repository owner") + flag.StringVar(&repoName, "repo-name", "", "Repository name") + flag.IntVar(&issueId, "issue-id", 0, "Issue id") + flag.Parse() + + // Get the diff file from the positional arguments + args := flag.Args() + if len(args) < 1 { + fmt.Println("Usage: forgejo-comment ") + os.Exit(1) + } + diffFile := args[0] + + // Read environment variables if flags are not set + if forgejoAPIURL == "" { + forgejoAPIURL = os.Getenv("FORGEJO_API_URL") + if forgejoAPIURL == "" { + fmt.Println("Error: FORGEJO_API_URL is not set") + os.Exit(1) + } + } + + if token == "" { + token = os.Getenv("FORGEJO_TOKEN") + if token == "" { + fmt.Println("Error: FORGEJO_TOKEN is not set") + os.Exit(1) + } + } + + if repoOwner == "" { + repoOwner = os.Getenv("REPO_OWNER") + if repoOwner == "" { + fmt.Println("Error: REPO_OWNER is not set") + os.Exit(1) + } + } + + if repoName == "" { + repoName = os.Getenv("REPO_NAME") + if repoName == "" { + fmt.Println("Error: REPO_NAME is not set") + os.Exit(1) + } + } + + if issueId == 0 { + issueIdEnv := os.Getenv("ISSUE_ID") + if issueIdEnv == "" { + fmt.Println("Error: ISSUE_ID is not set") + os.Exit(1) + } + var err error + issueId, err = strconv.Atoi(issueIdEnv) + if err != nil { + fmt.Println("Error: Invalid ISSUE_ID") + os.Exit(1) + } + } + + // Read the diff file + diffContentBytes, err := os.ReadFile(diffFile) + if err != nil { + fmt.Printf("Error reading diff file: %v\n", err) + os.Exit(1) + } + + // Prepare the comment content + diffContent := string(diffContentBytes) + commentBody := fmt.Sprintf("```diff\n%s\n```", diffContent) + + // Prepare the request payload + payload := map[string]string{ + "body": commentBody, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + fmt.Printf("Error marshaling payload: %v\n", err) + os.Exit(1) + } + + // Construct the API URL + apiURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", forgejoAPIURL, repoOwner, repoName, issueId) + + // Create the HTTP request + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + fmt.Printf("Error creating request: %v\n", err) + os.Exit(1) + } + + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + req.Header.Set("Content-Type", "application/json") + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Error sending request: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + // Read the response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("Error reading response: %v\n", err) + os.Exit(1) + } + + // Check the response status code + if resp.StatusCode != 201 { + fmt.Printf("Failed to post comment. HTTP status: %d\n", resp.StatusCode) + fmt.Printf("Response body: %s\n", string(respBody)) + os.Exit(1) + } else { + fmt.Printf("Posted diff to Forgejo issue #%d\n", issueId) + } +} diff --git a/apps/ci-os/packages/forgejo-release/README.md b/apps/ci-os/packages/forgejo-release/README.md new file mode 100644 index 0000000..e69de29 diff --git a/apps/ci-os/packages/forgejo-release/default.nix b/apps/ci-os/packages/forgejo-release/default.nix new file mode 100644 index 0000000..63f2433 --- /dev/null +++ b/apps/ci-os/packages/forgejo-release/default.nix @@ -0,0 +1,13 @@ +{ lib, pkgs }: + +pkgs.buildGoModule rec { + pname = "forgejo-release"; + version = "1.0.0"; + + src = ./src; + + # Vendor dependencies for reproducibility + vendorHash = null; + + subPackages = [ "." ]; +} \ No newline at end of file diff --git a/apps/ci-os/packages/forgejo-release/src/go.mod b/apps/ci-os/packages/forgejo-release/src/go.mod new file mode 100644 index 0000000..5bec627 --- /dev/null +++ b/apps/ci-os/packages/forgejo-release/src/go.mod @@ -0,0 +1,3 @@ +module code.252.no/tommy/forgejo-release + +go 1.22.6 diff --git a/apps/ci-os/packages/forgejo-release/src/main.go b/apps/ci-os/packages/forgejo-release/src/main.go new file mode 100644 index 0000000..b745863 --- /dev/null +++ b/apps/ci-os/packages/forgejo-release/src/main.go @@ -0,0 +1,826 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// API endpoint templates +const ( + tagURLTemplate = "%s/api/v1/repos/%s/tags/%s" + tagsURLTemplate = "%s/api/v1/repos/%s/tags" + releasesURLTemplate = "%s/api/v1/repos/%s/releases" + releaseByTagURLTemplate = "%s/api/v1/repos/%s/releases/tags/%s" + releaseAssetsURLTemplate = "%s/api/v1/repos/%s/releases/%d/assets" + releaseURLTemplate = "%s/api/v1/repos/%s/releases/%d" + latestReleaseURLTemplate = "%s/api/v1/repos/%s/releases/latest" +) + +// Release represents a Forgejo release. +type Release struct { + ID int64 `json:"id"` + Draft bool `json:"draft"` + Tag string `json:"tag_name"` + Assets []struct { + ID int64 `json:"id"` + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +func main() { + // Parse command-line flags and environment variables. + config, err := parseConfig() + if err != nil { + fmt.Printf("Configuration error: %v\n", err) + os.Exit(1) + } + + // Execute the specified command. + switch config.Command { + case "create": + err := createReleaseCommand(config) + if err != nil { + fmt.Printf("Release creation failed: %v\n", err) + os.Exit(1) + } + case "download": + err := downloadRelease(config) + if err != nil { + fmt.Printf("Download failed: %v\n", err) + os.Exit(1) + } + default: + fmt.Println("Unknown command. Use 'create' or 'download'.") + os.Exit(1) + } +} + +// Config holds the configuration parameters. +type Config struct { + ForgejoURL string + Repo string + Title string + ReleaseDir string + DownloadLatest bool + TmpDir string + Override bool + Retry int + Delay int + GPGPrivateKey string + GPGPassphrase string + Tag string + SHA string + Token string + Prerelease bool + Command string + Interactive bool +} + +// parseConfig parses command-line flags and environment variables. +func parseConfig() (*Config, error) { + var config Config + + // Check if the program is run without arguments (interactive mode). + if len(os.Args) == 1 { + config.Interactive = true + interactiveSetup(&config) + return &config, nil + } + + flag.StringVar(&config.ForgejoURL, "forgejo-url", getEnv("FORGEJO", "https://code.252.no"), "Forgejo instance URL") + flag.StringVar(&config.Repo, "repo", getEnv("REPO", ""), "Repository in the format owner/name (required)") + flag.StringVar(&config.Title, "title", getEnv("TITLE", ""), "Release title (defaults to tag)") + flag.StringVar(&config.ReleaseDir, "release-dir", getEnv("RELEASE_DIR", "dist/"), "Directory containing release assets") + flag.BoolVar(&config.DownloadLatest, "download-latest", getEnvBool("DOWNLOAD_LATEST", false), "Download the latest release") + flag.StringVar(&config.TmpDir, "tmp-dir", getEnv("TMP_DIR", ""), "Temporary directory") + flag.BoolVar(&config.Override, "override", getEnvBool("OVERRIDE", false), "Override existing release and tag") + flag.IntVar(&config.Retry, "retry", getEnvInt("RETRY", 1), "Number of retries when downloading") + flag.IntVar(&config.Delay, "delay", getEnvInt("DELAY", 10), "Delay between retries in seconds") + flag.StringVar(&config.GPGPrivateKey, "gpg-private-key", getEnv("GPG_PRIVATE_KEY", ""), "Path to GPG private key") + flag.StringVar(&config.GPGPassphrase, "gpg-passphrase", getEnv("GPG_PASSPHRASE", ""), "Path to GPG passphrase file") + flag.StringVar(&config.Tag, "tag", getEnv("TAG", ""), "Release tag (required for create)") + flag.StringVar(&config.SHA, "sha", getEnv("SHA", ""), "Commit SHA for the tag (optional)") + flag.StringVar(&config.Token, "token", getEnv("TOKEN", ""), "Forgejo API token (required)") + flag.BoolVar(&config.Prerelease, "prerelease", getEnvBool("PRERELEASE", false), "Mark the release as a prerelease") + + flag.Usage = func() { + fmt.Printf("Usage: %s [options]\n", os.Args[0]) + fmt.Println("\nCommands:") + fmt.Println(" create Create a new release with uploaded assets") + fmt.Println(" download Download assets from an existing release") + fmt.Println("\nOptions:") + flag.PrintDefaults() + fmt.Println("\nExamples:") + fmt.Printf(" %s create --repo=owner/repo --tag=v1.0.0 --token=your-token --release-dir=path/to/assets\n", os.Args[0]) + fmt.Printf(" %s download --repo=owner/repo --tag=v1.0.0 --token=your-token --release-dir=path/to/download\n", os.Args[0]) + } + + flag.Parse() + + // Get the command (create or download). + if len(flag.Args()) < 1 { + flag.Usage() + os.Exit(1) + } + config.Command = flag.Args()[0] + + // Validate required parameters. + if config.Repo == "" { + return nil, errors.New("repository (--repo) is required") + } + if config.Token == "" { + return nil, errors.New("API token (--token) is required") + } + if config.Command == "create" && config.Tag == "" { + return nil, errors.New("tag (--tag) is required for creating a release") + } + if config.Command == "download" && !config.DownloadLatest && config.Tag == "" { + return nil, errors.New("tag (--tag) is required unless --download-latest is true") + } + + return &config, nil +} + +// interactiveSetup prompts the user for configuration in interactive mode. +func interactiveSetup(config *Config) { + reader := bufio.NewReader(os.Stdin) + fmt.Println("Interactive Mode: Please provide the following details.") + + config.Command = promptChoice(reader, "Choose a command (create/download): ", []string{"create", "download"}, "create") + config.ForgejoURL = prompt(reader, "Forgejo instance URL", getEnv("FORGEJO", "https://code.252.no")) + config.Repo = prompt(reader, "Repository (owner/name)", "") + config.Token = prompt(reader, "Forgejo API token", "") + if config.Command == "create" { + config.ReleaseDir = prompt(reader, "Directory containing release assets", getEnv("RELEASE_DIR", "dist/")) + config.Tag = prompt(reader, "Release tag", "") + config.Title = prompt(reader, "Release title", config.Tag) + config.SHA = prompt(reader, "Commit SHA for the tag (leave blank if tag exists)", "") + config.Prerelease = promptBool(reader, "Is this a prerelease?", false) + config.Override = promptBool(reader, "Override existing release and tag if they exist?", false) + config.GPGPrivateKey = prompt(reader, "Path to GPG private key (optional)", "") + config.GPGPassphrase = prompt(reader, "Path to GPG passphrase file (optional)", "") + } else if config.Command == "download" { + config.ReleaseDir = prompt(reader, "Directory to download assets into", getEnv("RELEASE_DIR", "dist/")) + config.DownloadLatest = promptBool(reader, "Download the latest release?", false) + if !config.DownloadLatest { + config.Tag = prompt(reader, "Release tag to download", "") + } + config.Retry = promptInt(reader, "Number of retries if release is not available", 1) + config.Delay = promptInt(reader, "Delay between retries in seconds", 10) + } +} + +// Helper functions for interactive prompts. +func prompt(reader *bufio.Reader, label, defaultValue string) string { + if defaultValue != "" { + fmt.Printf("%s [%s]: ", label, defaultValue) + } else { + fmt.Printf("%s: ", label) + } + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return defaultValue + } + return input +} + +func promptBool(reader *bufio.Reader, label string, defaultValue bool) bool { + defaultStr := "no" + if defaultValue { + defaultStr = "yes" + } + for { + fmt.Printf("%s [%s]: ", label, defaultStr) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + if input == "" { + return defaultValue + } + if input == "yes" || input == "y" { + return true + } + if input == "no" || input == "n" { + return false + } + fmt.Println("Please enter 'yes' or 'no'.") + } +} + +func promptInt(reader *bufio.Reader, label string, defaultValue int) int { + for { + fmt.Printf("%s [%d]: ", label, defaultValue) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return defaultValue + } + value, err := strconv.Atoi(input) + if err == nil { + return value + } + fmt.Println("Please enter a valid integer.") + } +} + +func promptChoice(reader *bufio.Reader, label string, choices []string, defaultValue string) string { + choiceMap := make(map[string]bool) + for _, choice := range choices { + choiceMap[choice] = true + } + for { + fmt.Printf("%s [%s]: ", label, defaultValue) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + if input == "" { + return defaultValue + } + if _, valid := choiceMap[input]; valid { + return input + } + fmt.Printf("Invalid choice. Valid options are: %s\n", strings.Join(choices, ", ")) + } +} + +// createReleaseCommand handles the creation of a release. +func createReleaseCommand(config *Config) error { + // Create temporary directory if not provided. + if config.TmpDir == "" { + var err error + config.TmpDir, err = os.MkdirTemp("", "forgejo-release") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(config.TmpDir) + } + + // Override existing release and tag if requested. + if config.Override { + if err := deleteRelease(config); err != nil { + return err + } + if err := deleteTag(config); err != nil { + return err + } + } + + // Ensure the tag exists. + if err := ensureTag(config); err != nil { + return fmt.Errorf("failed to ensure tag: %w", err) + } + + // Sign release assets if GPG key is provided. + if config.GPGPrivateKey != "" { + if err := signReleaseAssets(config); err != nil { + return fmt.Errorf("failed to sign assets: %w", err) + } + } + + // Create and publish the release. + if err := createAndPublishRelease(config); err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + fmt.Printf("Release '%s' created successfully.\n", config.Tag) + return nil +} + +// uploadRelease handles the upload of a release. +func uploadRelease(config *Config) error { + // Create temporary directory if not provided. + if config.TmpDir == "" { + var err error + config.TmpDir, err = os.MkdirTemp("", "forgejo-release") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(config.TmpDir) + } + + // Override existing release and tag if requested. + if config.Override { + if err := deleteRelease(config); err != nil { + return err + } + if err := deleteTag(config); err != nil { + return err + } + } + + // Ensure the tag exists. + if err := ensureTag(config); err != nil { + return fmt.Errorf("failed to ensure tag: %w", err) + } + + // Sign release assets if GPG key is provided. + if config.GPGPrivateKey != "" { + if err := signReleaseAssets(config); err != nil { + return fmt.Errorf("failed to sign assets: %w", err) + } + } + + // Create and publish the release. + if err := createAndPublishRelease(config); err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + return nil +} + +// downloadRelease handles the download of a release. +func downloadRelease(config *Config) error { + // Create temporary directory if not provided. + if config.TmpDir == "" { + var err error + config.TmpDir, err = os.MkdirTemp("", "forgejo-release") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(config.TmpDir) + } + + // Retrieve the release information. + release, err := getRelease(config) + if err != nil { + return err + } + + // Download the assets. + if err := downloadAssets(config, release); err != nil { + return fmt.Errorf("failed to download assets: %w", err) + } + + return nil +} + +// ensureTag checks if the tag exists and creates it if it doesn't. +func ensureTag(config *Config) error { + tagSHA, err := getTagSHA(config) + if err != nil { + if errors.Is(err, errTagNotFound) { + // Tag does not exist; create it if SHA is provided. + if config.SHA == "" { + return errors.New("tag does not exist and SHA is not provided to create it") + } + if err := createTag(config); err != nil { + return fmt.Errorf("failed to create tag: %w", err) + } + fmt.Printf("Tag '%s' created successfully.\n", config.Tag) + } else { + return err + } + } else { + // Tag exists; verify SHA if provided. + if config.SHA != "" && config.SHA != tagSHA { + return errors.New("tag SHA does not match the provided SHA") + } + // Use the tag's SHA if not provided. + if config.SHA == "" { + config.SHA = tagSHA + } + fmt.Printf("Tag '%s' already exists.\n", config.Tag) + } + return nil +} + +// getTagSHA retrieves the SHA associated with the tag. +func getTagSHA(config *Config) (string, error) { + apiURL := fmt.Sprintf(tagURLTemplate, config.ForgejoURL, config.Repo, config.Tag) + resp, err := apiRequest("GET", apiURL, nil, config.Token) + if err != nil { + return "", fmt.Errorf("failed to get tag: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + var result struct { + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode tag response: %w", err) + } + return result.Commit.SHA, nil + } else if resp.StatusCode == http.StatusNotFound { + return "", errTagNotFound + } + + bodyBytes, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to get tag: %s", string(bodyBytes)) +} + +var errTagNotFound = errors.New("tag not found") + +// createTag creates a new tag in the repository. +func createTag(config *Config) error { + payload := map[string]string{ + "tag_name": config.Tag, + "target": config.SHA, + } + payloadBytes, _ := json.Marshal(payload) + apiURL := fmt.Sprintf(tagsURLTemplate, config.ForgejoURL, config.Repo) + resp, err := apiRequest("POST", apiURL, bytes.NewBuffer(payloadBytes), config.Token) + if err != nil { + return fmt.Errorf("failed to create tag: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to create tag: %s", string(bodyBytes)) + } + return nil +} + +// createAndPublishRelease creates a new release and uploads assets. +func createAndPublishRelease(config *Config) error { + // Create the release. + releaseID, err := createRelease(config) + if err != nil { + return err + } + + // Upload assets. + if err := uploadAssets(config, releaseID); err != nil { + return err + } + + // Publish the release. + if err := updateReleaseDraftState(config, releaseID, false); err != nil { + return err + } + + return nil +} + +// createRelease creates a new release and returns its ID. +func createRelease(config *Config) (int64, error) { + isPrerelease := config.Prerelease || strings.Contains(strings.ToLower(config.Tag), "-rc") + title := config.Title + if title == "" { + title = config.Tag + } + payload := map[string]interface{}{ + "tag_name": config.Tag, + "title": title, + "prerelease": isPrerelease, + "draft": true, + "body": "", + } + payloadBytes, _ := json.Marshal(payload) + + apiURL := fmt.Sprintf(releasesURLTemplate, config.ForgejoURL, config.Repo) + resp, err := apiRequest("POST", apiURL, bytes.NewBuffer(payloadBytes), config.Token) + if err != nil { + return 0, fmt.Errorf("failed to create release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("failed to create release: %s", string(bodyBytes)) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return 0, fmt.Errorf("failed to decode release response: %w", err) + } + + return release.ID, nil +} + +// uploadAssets uploads the assets to the release. +func uploadAssets(config *Config, releaseID int64) error { + entries, err := os.ReadDir(config.ReleaseDir) + if err != nil { + return fmt.Errorf("failed to read release directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + filePath := filepath.Join(config.ReleaseDir, entry.Name()) + if err := uploadAsset(config, releaseID, filePath); err != nil { + return fmt.Errorf("failed to upload asset %s: %w", entry.Name(), err) + } + } + return nil +} + +// uploadAsset uploads a single asset to the release. +func uploadAsset(config *Config, releaseID int64, filePath string) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open asset file: %w", err) + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to get file info: %w", err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", fileInfo.Name()) + if err != nil { + return fmt.Errorf("failed to create form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return fmt.Errorf("failed to copy file content: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("failed to close multipart writer: %w", err) + } + + apiURL := fmt.Sprintf(releaseAssetsURLTemplate, config.ForgejoURL, config.Repo, releaseID) + req, err := http.NewRequest("POST", apiURL, body) + if err != nil { + return fmt.Errorf("failed to create upload request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", fmt.Sprintf("token %s", config.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to upload asset: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to upload asset: %s", string(bodyBytes)) + } + + return nil +} + +// updateReleaseDraftState updates the draft state of the release. +func updateReleaseDraftState(config *Config, releaseID int64, state bool) error { + payload := map[string]bool{ + "draft": state, + } + payloadBytes, _ := json.Marshal(payload) + apiURL := fmt.Sprintf(releaseURLTemplate, config.ForgejoURL, config.Repo, releaseID) + resp, err := apiRequest("PATCH", apiURL, bytes.NewBuffer(payloadBytes), config.Token) + if err != nil { + return fmt.Errorf("failed to update release draft state: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to update release draft state: %s", string(bodyBytes)) + } + return nil +} + +// signReleaseAssets signs the release assets using GPG. +func signReleaseAssets(config *Config) error { + gpgHome, err := os.MkdirTemp("", "gpg") + if err != nil { + return fmt.Errorf("failed to create GPG home directory: %w", err) + } + defer os.RemoveAll(gpgHome) + + // Import GPG private key. + gpgArgs := []string{"--batch", "--yes", "--import", config.GPGPrivateKey} + if config.GPGPassphrase != "" { + gpgArgs = append([]string{"--passphrase-file", config.GPGPassphrase}, gpgArgs...) + } + cmd := exec.Command("gpg", gpgArgs...) + cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to import GPG key: %w, output: %s", err, string(output)) + } + + // Sign each asset. + entries, err := os.ReadDir(config.ReleaseDir) + if err != nil { + return fmt.Errorf("failed to read release directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || strings.HasSuffix(entry.Name(), ".sha256") { + continue + } + filePath := filepath.Join(config.ReleaseDir, entry.Name()) + sigPath := filePath + ".asc" + + cmdArgs := []string{"--armor", "--detach-sign", "--output", sigPath, filePath} + if config.GPGPassphrase != "" { + cmdArgs = append([]string{"--batch", "--yes", "--passphrase-file", config.GPGPassphrase}, cmdArgs...) + } + cmd = exec.Command("gpg", cmdArgs...) + cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to sign asset %s: %w, output: %s", entry.Name(), err, string(output)) + } + } + + return nil +} + +// deleteRelease deletes an existing release by tag. +func deleteRelease(config *Config) error { + apiURL := fmt.Sprintf(releaseByTagURLTemplate, config.ForgejoURL, config.Repo, config.Tag) + resp, err := apiRequest("DELETE", apiURL, nil, config.Token) + if err != nil { + return fmt.Errorf("failed to delete release: %w", err) + } + resp.Body.Close() + return nil +} + +// deleteTag deletes an existing tag. +func deleteTag(config *Config) error { + apiURL := fmt.Sprintf("%s/api/v1/repos/%s/tags/%s", config.ForgejoURL, config.Repo, config.Tag) + resp, err := apiRequest("DELETE", apiURL, nil, config.Token) + if err != nil { + return fmt.Errorf("failed to delete tag: %w", err) + } + resp.Body.Close() + return nil +} + +// getRelease retrieves the release information. +func getRelease(config *Config) (*Release, error) { + var release *Release + var err error + + for i := 0; i < config.Retry; i++ { + if config.DownloadLatest { + release, err = getLatestRelease(config) + } else { + release, err = getReleaseByTag(config) + } + + if err == nil && !release.Draft { + break + } + + fmt.Printf("Release not ready yet, retrying in %d seconds...\n", config.Delay) + time.Sleep(time.Duration(config.Delay) * time.Second) + } + + if release == nil || release.Draft { + return nil, errors.New("release not available") + } + + return release, nil +} + +// getLatestRelease retrieves the latest release. +func getLatestRelease(config *Config) (*Release, error) { + apiURL := fmt.Sprintf(latestReleaseURLTemplate, config.ForgejoURL, config.Repo) + resp, err := apiRequest("GET", apiURL, nil, config.Token) + if err != nil { + return nil, fmt.Errorf("failed to get latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get latest release: %s", string(bodyBytes)) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to decode latest release: %w", err) + } + return &release, nil +} + +// getReleaseByTag retrieves a release by its tag. +func getReleaseByTag(config *Config) (*Release, error) { + apiURL := fmt.Sprintf("%s/api/v1/repos/%s/releases/tags/%s", config.ForgejoURL, config.Repo, config.Tag) + resp, err := apiRequest("GET", apiURL, nil, config.Token) + if err != nil { + return nil, fmt.Errorf("failed to get release by tag: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get release by tag: %s", string(bodyBytes)) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to decode release by tag: %w", err) + } + return &release, nil +} + +func downloadAssets(config *Config, release *Release) error { + if err := os.MkdirAll(config.ReleaseDir, 0755); err != nil { + return fmt.Errorf("failed to create release directory: %w", err) + } + + for _, asset := range release.Assets { + assetPath := filepath.Join(config.ReleaseDir, asset.Name) + if err := downloadAsset(config, asset.BrowserDownloadURL, assetPath); err != nil { + return fmt.Errorf("failed to download asset %s: %w", asset.Name, err) + } + fmt.Printf("Downloaded asset: %s\n", asset.Name) + } + + fmt.Println("All assets downloaded successfully.") + return nil +} + +// downloadAsset downloads a single asset. +func downloadAsset(config *Config, url, filepath string) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", config.Token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to download asset: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to download asset: %s", string(bodyBytes)) + } + + outFile, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, resp.Body); err != nil { + return fmt.Errorf("failed to write asset to file: %w", err) + } + + return nil +} + +// apiRequest sends an HTTP request to the Forgejo API. +func apiRequest(method, url string, body io.Reader, token string) (*http.Response, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create API request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("API request failed: %w", err) + } + + return resp, nil +} + +// Helper functions to get environment variables with defaults. +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if valueStr, exists := os.LookupEnv(key); exists { + if value, err := strconv.Atoi(valueStr); err == nil { + return value + } + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if valueStr, exists := os.LookupEnv(key); exists { + if value, err := strconv.ParseBool(valueStr); err == nil { + return value + } + } + return defaultValue +} diff --git a/apps/lix-builder/manifest.yaml b/apps/lix-builder/manifest.yaml deleted file mode 100644 index 739d63a..0000000 --- a/apps/lix-builder/manifest.yaml +++ /dev/null @@ -1,102 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: builds - labels: - pod-security.kubernetes.io/enforce: privileged - pod-security.kubernetes.io/enforce-version: latest - pod-security.kubernetes.io/warn: privileged - pod-security.kubernetes.io/warn-version: latest - pod-security.kubernetes.io/audit: privileged - pod-security.kubernetes.io/audit-version: latest ---- -apiVersion: v1 -kind: Pod -metadata: - name: kaniko - namespace: builds -spec: - securityContext: - runAsUser: 1001 - runAsGroup: 1001 - fsGroup: 1001 - seccompProfile: - type: Unconfined - containers: - - name: kaniko - image: code.252.no/tommy/kaniko:v24.10.01@sha256:d51c3b5c468bb070108d9e27884072f8527f20c9e41e2133621c56f62f89afc0 - resources: - limits: - cpu: 1000m - memory: 2Gi - command: ["/opt/kaniko/kaniko"] - args: - - --dockerfile=Dockerfile - #- --reproducible - - --context=/kaniko - - --custom-platform=linux/amd64 - - --destination=code.252.no/tommy/lix-builder:v24.10.01 - #- --dockerfile=Dockerfile - #- --reproducible - #- --kaniko-dir=/workspace/kaniko - #- --context=/workspace - #- --custom-platform=linux/amd64 - #- --destination=code.252.no/tommy/lix-builder:v24.10.01 - #- --cache=true - #- --compressed-caching=false - #- --use-new-run - #- --cleanup - volumeMounts: - # - name: workspace-dir - # mountPath: /workspace - - name: docker-config - mountPath: /opt/kaniko/.docker/config.json - subPath: config.json - - name: dockerfile - mountPath: /kaniko/Dockerfile - subPath: Dockerfile - securityContext: - runAsUser: 1001 - privileged: false - allowPrivilegeEscalation: false - runAsNonRoot: true - capabilities: - drop: - - ALL - add: - - CHOWN - - FOWNER - - DAC_OVERRIDE - - SYS_ADMIN - restartPolicy: Never - volumes: - # - name: workspace-dir - # emptyDir: {} - - name: docker-config - secret: - secretName: tommy-pushsecret-rw - items: - - key: .dockerconfigjson - path: config.json - - name: dockerfile - configMap: - name: dockerfile ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: dockerfile - namespace: builds -data: - Dockerfile: | - FROM ghcr.io/lix-project/lix:2.91 - - WORKDIR /tmp/working-dir - - RUN nix-env -iA nixpkgs.go nixpkgs.vim nixpkgs.sops nixpkgs.nix-direnv \ - nixpkgs.attic-client nixpkgs.nh nixpkgs.deploy-rs nixpkgs.statix \ - nixpkgs.deadnix nixpkgs.alejandra nixpkgs.home-manager \ - nixpkgs.ssh-to-age nixpkgs.gnupg nixpkgs.age nixpkgs.linux \ - nixpkgs.go-task nixpkgs.curl nixpkgs.fish nixpkgs.nixos-anywhere - - ENTRYPOINT ["fish"] diff --git a/apps/lix-builder/metadata.yaml b/apps/lix-builder/metadata.yaml deleted file mode 100644 index 8a6c0ae..0000000 --- a/apps/lix-builder/metadata.yaml +++ /dev/null @@ -1,9 +0,0 @@ -app: lix-builder -version: v24.10.01 -channels: -- name: stable - platforms: ["linux/amd64"] - stable: false - tests: - enabled: true - type: cli diff --git a/apps/lix-builder/packages/forgejo-comment/default.nix b/apps/lix-builder/packages/forgejo-comment/default.nix deleted file mode 100644 index 113714c..0000000 --- a/apps/lix-builder/packages/forgejo-comment/default.nix +++ /dev/null @@ -1,20 +0,0 @@ -{ lib, pkgs }: - -pkgs.buildGoModule rec { - pname = "forgejo-comment"; - version = "1.0.0"; - - src = ./src; - - # Vendor dependencies for reproducibility - vendorHash = null; - - subPackages = [ "." ]; - - meta = { - homepage = "https://code.252.no/tommy/containers"; - description = "Comment on Forgejo issues via the Forgejo API"; - license = licenses.mit; - maintainers = with maintainers; [ "tommy-skaug" ]; - }; -} \ No newline at end of file diff --git a/apps/lix-builder/packages/forgejo-comment/src/main.go b/apps/lix-builder/packages/forgejo-comment/src/main.go deleted file mode 100644 index 869e2df..0000000 --- a/apps/lix-builder/packages/forgejo-comment/src/main.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "flag" - "fmt" - "io/ioutil" - "net/http" - "os" - "strconv" -) - -func main() { - // Define command-line flags - var forgejoAPIURL string - var token string - var repoOwner string - var repoName string - var issueIndex int - - flag.StringVar(&forgejoAPIURL, "forgejo-api-url", "", "Forgejo API URL") - flag.StringVar(&token, "token", "", "Forgejo API token") - flag.StringVar(&repoOwner, "repo-owner", "", "Repository owner") - flag.StringVar(&repoName, "repo-name", "", "Repository name") - flag.IntVar(&issueIndex, "issue-index", 0, "Issue index") - flag.Parse() - - // Get the diff file from the positional arguments - args := flag.Args() - if len(args) < 1 { - fmt.Println("Usage: forgejo-comment ") - os.Exit(1) - } - diffFile := args[0] - - // Read environment variables if flags are not set - if forgejoAPIURL == "" { - forgejoAPIURL = os.Getenv("FORGEJO_API_URL") - if forgejoAPIURL == "" { - fmt.Println("Error: FORGEJO_API_URL is not set") - os.Exit(1) - } - } - - if token == "" { - token = os.Getenv("FORGEJO_TOKEN") - if token == "" { - fmt.Println("Error: FORGEJO_TOKEN is not set") - os.Exit(1) - } - } - - if repoOwner == "" { - repoOwner = os.Getenv("REPO_OWNER") - if repoOwner == "" { - fmt.Println("Error: REPO_OWNER is not set") - os.Exit(1) - } - } - - if repoName == "" { - repoName = os.Getenv("REPO_NAME") - if repoName == "" { - fmt.Println("Error: REPO_NAME is not set") - os.Exit(1) - } - } - - if issueIndex == 0 { - issueIndexEnv := os.Getenv("ISSUE_INDEX") - if issueIndexEnv == "" { - fmt.Println("Error: ISSUE_INDEX is not set") - os.Exit(1) - } - var err error - issueIndex, err = strconv.Atoi(issueIndexEnv) - if err != nil { - fmt.Println("Error: Invalid ISSUE_INDEX") - os.Exit(1) - } - } - - // Read the diff file - diffContentBytes, err := ioutil.ReadFile(diffFile) - if err != nil { - fmt.Printf("Error reading diff file: %v\n", err) - os.Exit(1) - } - - // Prepare the comment content - diffContent := string(diffContentBytes) - commentBody := fmt.Sprintf("```diff\n%s\n```", diffContent) - - // Prepare the request payload - payload := map[string]string{ - "body": commentBody, - } - payloadBytes, err := json.Marshal(payload) - if err != nil { - fmt.Printf("Error marshaling payload: %v\n", err) - os.Exit(1) - } - - // Construct the API URL - apiURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", forgejoAPIURL, repoOwner, repoName, issueIndex) - - // Create the HTTP request - req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) - if err != nil { - fmt.Printf("Error creating request: %v\n", err) - os.Exit(1) - } - - req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) - req.Header.Set("Content-Type", "application/json") - - // Send the request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Printf("Error sending request: %v\n", err) - os.Exit(1) - } - defer resp.Body.Close() - - // Read the response body - respBody, err := ioutil.ReadAll(resp.Body) - if err != nil { - fmt.Printf("Error reading response: %v\n", err) - os.Exit(1) - } - - // Check the response status code - if resp.StatusCode != 201 { - fmt.Printf("Failed to post comment. HTTP status: %d\n", resp.StatusCode) - fmt.Printf("Response body: %s\n", string(respBody)) - os.Exit(1) - } else { - fmt.Printf("Posted diff to Forgejo issue #%d\n", issueIndex) - } -}