├── .github └── workflows │ ├── gosec.yaml │ ├── govulncheck.yaml │ ├── issue_assistant.yaml │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── config.yaml ├── docs ├── gama.gif └── generate_github_token │ ├── README.md │ ├── perm1.png │ ├── perm2.png │ ├── perm3.png │ ├── permissions.png │ └── repos.png ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ ├── settings.go │ └── shortcuts.go ├── github │ ├── domain │ │ └── domain.go │ ├── repository │ │ ├── httpclient.go │ │ ├── ports.go │ │ ├── repository.go │ │ ├── repository_test.go │ │ └── types.go │ └── usecase │ │ ├── ports.go │ │ ├── types.go │ │ ├── usecase.go │ │ └── usecase_test.go └── terminal │ └── handler │ ├── ghinformation.go │ ├── ghrepository.go │ ├── ghtrigger.go │ ├── ghworkflow.go │ ├── ghworkflowhistory.go │ ├── handler.go │ ├── keymap.go │ ├── status.go │ ├── table.go │ ├── taboptions.go │ └── types.go ├── main.go └── pkg ├── browser └── browser.go ├── version ├── httpclient.go ├── ports.go ├── version.go └── version_test.go ├── workflow ├── workflow.go └── workflow_test.go └── yaml ├── yaml.go └── yaml_test.go /.github/workflows/gosec.yaml: -------------------------------------------------------------------------------- 1 | name: Run Gosec 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | gosec: 11 | runs-on: ubuntu-latest 12 | env: 13 | GO111MODULE: on 14 | steps: 15 | - name: Checkout Source 16 | uses: actions/checkout@v4 17 | - name: Run Gosec Security Scanner 18 | uses: securego/gosec@master 19 | with: 20 | args: ./... -------------------------------------------------------------------------------- /.github/workflows/govulncheck.yaml: -------------------------------------------------------------------------------- 1 | name: Run govulncheck 2 | on: [push] 3 | jobs: 4 | govulncheck: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - id: govulncheck 8 | uses: golang/govulncheck-action@v1 9 | -------------------------------------------------------------------------------- /.github/workflows/issue_assistant.yaml: -------------------------------------------------------------------------------- 1 | name: Issue Assistant 2 | on: 3 | issues: 4 | types: [opened] 5 | 6 | jobs: 7 | analyze: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | contents: read 12 | steps: 13 | - uses: workflowkit/issue-assistant@v1.0.0 14 | with: 15 | github_token: ${{ secrets.GITHUB_TOKEN }} 16 | ai_type: "claude" 17 | claude_api_key: ${{ secrets.CLAUDE_API_KEY }} 18 | enable_comment: "true" 19 | enable_label: "true" -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build_and_release: 10 | name: Build for Multiple Platforms and Create Release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Code 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: '1.23' 21 | 22 | - name: Get the latest tag 23 | id: get_latest_tag 24 | run: echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV 25 | 26 | - name: Build for Linux, Windows, macOS using Makefile 27 | run: make all 28 | 29 | - name: Create Release 30 | uses: ncipollo/release-action@v1 31 | with: 32 | artifacts: "release/gama-linux-amd64,release/gama-linux-amd64.sha256,release/gama-linux-arm64,release/gama-linux-arm64.sha256,release/gama-windows-amd64.exe,release/gama-windows-amd64.exe.sha256,release/gama-macos-amd64,release/gama-macos-amd64.sha256,release/gama-macos-arm64,release/gama-macos-arm64.sha256" 33 | token: ${{ secrets.GIT_TOKEN }} 34 | draft: true 35 | 36 | # Docker build and push steps 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v1 39 | 40 | - name: Login to Docker Hub 41 | uses: docker/login-action@v1 42 | with: 43 | username: ${{ secrets.DOCKER_USERNAME }} 44 | password: ${{ secrets.DOCKER_TOKEN }} 45 | 46 | - name: Build and push Docker image 47 | uses: docker/build-push-action@v2 48 | with: 49 | context: . 50 | file: ./Dockerfile 51 | push: true 52 | platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 53 | tags: | 54 | ${{ secrets.DOCKER_USERNAME }}/gama:${{ env.LATEST_TAG }} 55 | ${{ secrets.DOCKER_USERNAME }}/gama:latest 56 | build-args: | 57 | TERM=xterm-256color -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # ide data 25 | .idea 26 | 27 | .vscode 28 | 29 | # release 30 | release/ 31 | 32 | # binary 33 | main 34 | gama 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This dockerfile is used by workflow to build the docker image 2 | # It is not used to build the binary from scratch 3 | FROM alpine:latest 4 | 5 | WORKDIR /app 6 | 7 | COPY release/gama-linux-amd64 /app/gama 8 | 9 | # Set environment variable for color output 10 | ENV TERM xterm-256color 11 | 12 | ENTRYPOINT ["/app/gama"] 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all linux_amd64 linux_arm64 windows macos_amd64 macos_arm64 build get_latest_tag 2 | 3 | LATEST_TAG ?= $(shell git describe --tags `git rev-list --tags --max-count=1`) 4 | 5 | all: get_latest_tag linux_amd64 linux_arm64 windows macos_amd64 macos_arm64 6 | 7 | linux_amd64: get_latest_tag 8 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(LATEST_TAG)" -o release/gama-linux-amd64 main.go 9 | sha256sum release/gama-linux-amd64 > release/gama-linux-amd64.sha256 10 | 11 | linux_arm64: get_latest_tag 12 | GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(LATEST_TAG)" -o release/gama-linux-arm64 main.go 13 | sha256sum release/gama-linux-arm64 > release/gama-linux-arm64.sha256 14 | 15 | windows: get_latest_tag 16 | GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(LATEST_TAG)" -o release/gama-windows-amd64.exe main.go 17 | sha256sum release/gama-windows-amd64.exe > release/gama-windows-amd64.exe.sha256 18 | 19 | macos_amd64: get_latest_tag 20 | GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(LATEST_TAG)" -o release/gama-macos-amd64 main.go 21 | sha256sum release/gama-macos-amd64 > release/gama-macos-amd64.sha256 22 | 23 | macos_arm64: get_latest_tag 24 | GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(LATEST_TAG)" -o release/gama-macos-arm64 main.go 25 | sha256sum release/gama-macos-arm64 > release/gama-macos-arm64.sha256 26 | 27 | build: get_latest_tag # build for current OS 28 | CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(LATEST_TAG)" -o release/gama main.go 29 | 30 | get_latest_tag: 31 | @echo "Getting latest Git tag..." 32 | @echo $(LATEST_TAG) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Manager (GAMA) 2 | 3 | GAMA Go Version 4 | GAMA Go Report Card 5 | GAMA Licence 6 | 7 | GAMA is a powerful terminal-based user interface tool designed to streamline the management of GitHub Actions workflows. It allows developers to list, trigger, and manage workflows with ease directly from the terminal. 8 | 9 | ![gama demo](docs/gama.gif) 10 | 11 | ## Table of Contents 12 | - [Key Features](#key-features) 13 | - [Live Mode](#live-mode) 14 | - [Getting Started](#getting-started) 15 | - [Prerequisites](#prerequisites) 16 | - [Configuration](#configuration) 17 | - [Build & Installation](#build--installation) 18 | - [Contributing](#contributing) 19 | - [License](#license) 20 | - [Contact & Author](#contact--author) 21 | 22 | ## Key Features 23 | 24 | - **Extended Workflow Inputs**: Supports more than 10 workflow inputs using JSON format. 25 | - **Workflow History**: Conveniently list all historical runs of workflows in a repository. 26 | - **Discoverability**: Easily list all triggerable (dispatchable) workflows in a repository. 27 | - **Workflow Management**: Trigger specific workflows with custom inputs. 28 | - **Live Updates**: Automatically refresh workflow status at configurable intervals. 29 | - **Docker Support**: Run directly from a container for easy deployment. 30 | 31 | ### Live Mode 32 | 33 | GAMA includes a live mode feature that automatically refreshes the workflow status at regular intervals: 34 | 35 | - **Toggle Live Updates**: Press `ctrl+l` to turn live mode on/off 36 | - **Auto-start**: Set `settings.live_mode.enabled: true` to start GAMA with live mode enabled 37 | - **Refresh Interval**: Configure how often the view updates with `settings.live_mode.interval` (e.g., "15s", "1m") 38 | 39 | Live mode is particularly useful when monitoring ongoing workflow runs, as it eliminates the need for manual refreshing. 40 | 41 | ## Getting Started 42 | 43 | ### Prerequisites 44 | 45 | Before using GAMA, you need to generate a GitHub token. Follow these [instructions](docs/generate_github_token/README.md) to create your token. 46 | 47 | ### Configuration 48 | 49 | #### YAML Configuration 50 | 51 | Place a `~/.config/gama/config.yaml` file in your home directory with the following content: 52 | 53 | ```yaml 54 | github: 55 | token: 56 | 57 | keys: 58 | switch_tab_right: shift+right 59 | switch_tab_left: shift+left 60 | quit: ctrl+c 61 | refresh: ctrl+r 62 | live_mode: ctrl+l # Toggle live mode on/off 63 | enter: enter 64 | tab: tab 65 | 66 | settings: 67 | live_mode: 68 | enabled: true # Enable live mode at startup 69 | interval: 15s # Refresh interval for live updates 70 | ``` 71 | 72 | #### Environment Variable Configuration 73 | 74 | Alternatively, you can use an environment variable: 75 | 76 | ```bash 77 | GITHUB_TOKEN="" gama 78 | ``` 79 | 80 | You can also make it an alias for a better experience: 81 | 82 | ```bash 83 | alias gama='GITHUB_TOKEN="" command gama' 84 | ``` 85 | 86 | If you have the [GitHub CLI](https://cli.github.com/) installed, you automatically insert the var via: 87 | 88 | ```bash 89 | GITHUB_TOKEN="$(gh auth token)" gama 90 | ``` 91 | 92 | This will skip needing to generate a token via the GitHub website. 93 | 94 | > [!WARNING] 95 | > For security reasons, you should not `export` your token globally in your shell. 96 | > That would make it available to any app that can read environment variables. 97 | > You should avoid committing it to your dotfiles repository, too. 98 | 99 | ## Build & Installation 100 | 101 | ### Using Docker 102 | 103 | Run GAMA in a Docker container: 104 | 105 | ```bash 106 | docker run --rm -it --env GITHUB_TOKEN="" termkit/gama:latest 107 | ``` 108 | 109 | ### Download Binary 110 | 111 | Download the latest binary from the [releases page](https://github.com/termkit/gama/releases). 112 | 113 | ### Build from Source 114 | 115 | ```bash 116 | make build 117 | # output: ./release/gama 118 | ``` 119 | 120 | --- 121 | 122 | ## Contributing 123 | 124 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 125 | 126 | ## License 127 | 128 | Distributed under the GNU GENERAL PUBLIC LICENSE Version 3 or later. See [LICENSE](LICENSE) for more information. 129 | 130 | ## Contact & Author 131 | 132 | [Engin Açıkgöz](https://github.com/canack) 133 | 134 | ## Stargazers over time 135 | 136 | [![Stargazers over time](https://starchart.cc/termkit/gama.svg?variant=adaptive)](https://starchart.cc/termkit/gama) 137 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | github: 2 | token: 3 | 4 | keys: 5 | switch_tab_right: shift+right 6 | switch_tab_left: shift+left 7 | quit: ctrl+c 8 | refresh: ctrl+r 9 | live_mode: ctrl+l 10 | enter: enter 11 | tab: tab 12 | 13 | settings: 14 | live_mode: 15 | enabled: true # to enable live mode at startup 16 | interval: 15s # interval to refresh the page 17 | -------------------------------------------------------------------------------- /docs/gama.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/termkit/gama/0830db94f899e25f8aee56443b9dc6e76bdcc6b6/docs/gama.gif -------------------------------------------------------------------------------- /docs/generate_github_token/README.md: -------------------------------------------------------------------------------- 1 | # Generate GitHub Token from GAMA ("Fine-Grained Token") 2 | 3 | Generate a GitHub token with specific permissions for optimal use with GAMA. Follow these steps: 4 | 5 | 1. **Open the Fine-Grained Token Page** 6 | - Navigate to the fine-grained token page. 7 | - Click on the "Generate new token" button. 8 | 9 | 2. **Choose Repositories** 10 | - Decide the scope of the token: `All repositories` or `Only selected repositories`. 11 | ![Token Repositories Selection](repos.png) 12 | 13 | 3. **Set Required Permissions** 14 | - Scroll through the permissions list and enable the following: 15 | 16 | - **First Permission**: Necessary to trigger workflows. 17 | ![Permission to Run Workflows](perm1.png) 18 | 19 | - **Second Permission**: Essential to list triggerable workflows. 20 | ![Permission to Read Workflows](perm2.png) 21 | 22 | - **Third Permission**: Required to read repository contents, enabling workflow triggering. 23 | ![Permission to Read Repository Contents](perm3.png) 24 | 25 | 4. **Finalize** 26 | - After setting the permissions, complete the token generation process. 27 | 28 | Now, you can utilize this token with GAMA to manage GitHub Actions workflows effectively. 29 | -------------------------------------------------------------------------------- /docs/generate_github_token/perm1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/termkit/gama/0830db94f899e25f8aee56443b9dc6e76bdcc6b6/docs/generate_github_token/perm1.png -------------------------------------------------------------------------------- /docs/generate_github_token/perm2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/termkit/gama/0830db94f899e25f8aee56443b9dc6e76bdcc6b6/docs/generate_github_token/perm2.png -------------------------------------------------------------------------------- /docs/generate_github_token/perm3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/termkit/gama/0830db94f899e25f8aee56443b9dc6e76bdcc6b6/docs/generate_github_token/perm3.png -------------------------------------------------------------------------------- /docs/generate_github_token/permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/termkit/gama/0830db94f899e25f8aee56443b9dc6e76bdcc6b6/docs/generate_github_token/permissions.png -------------------------------------------------------------------------------- /docs/generate_github_token/repos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/termkit/gama/0830db94f899e25f8aee56443b9dc6e76bdcc6b6/docs/generate_github_token/repos.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/termkit/gama 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.3.1 7 | github.com/charmbracelet/bubbles v0.20.0 8 | github.com/charmbracelet/bubbletea v1.3.5 9 | github.com/charmbracelet/lipgloss v1.1.0 10 | github.com/spf13/viper v1.20.1 11 | github.com/stretchr/testify v1.10.0 12 | github.com/termkit/skeleton v0.2.2 13 | gopkg.in/yaml.v3 v3.0.1 14 | ) 15 | 16 | require ( 17 | github.com/atotto/clipboard v0.1.4 // indirect 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 20 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 21 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 22 | github.com/charmbracelet/x/term v0.2.1 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 25 | github.com/fsnotify/fsnotify v1.8.0 // indirect 26 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 27 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 28 | github.com/mattn/go-isatty v0.0.20 // indirect 29 | github.com/mattn/go-localereader v0.0.1 // indirect 30 | github.com/mattn/go-runewidth v0.0.16 // indirect 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 32 | github.com/muesli/cancelreader v0.2.2 // indirect 33 | github.com/muesli/termenv v0.16.0 // indirect 34 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 35 | github.com/pmezard/go-difflib v1.0.0 // indirect 36 | github.com/rivo/uniseg v0.4.7 // indirect 37 | github.com/sagikazarmark/locafero v0.7.0 // indirect 38 | github.com/sourcegraph/conc v0.3.0 // indirect 39 | github.com/spf13/afero v1.12.0 // indirect 40 | github.com/spf13/cast v1.7.1 // indirect 41 | github.com/spf13/pflag v1.0.6 // indirect 42 | github.com/subosito/gotenv v1.6.0 // indirect 43 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 44 | go.uber.org/atomic v1.9.0 // indirect 45 | go.uber.org/multierr v1.9.0 // indirect 46 | golang.org/x/sync v0.13.0 // indirect 47 | golang.org/x/sys v0.32.0 // indirect 48 | golang.org/x/text v0.21.0 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 2 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 8 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 9 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 10 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 11 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 12 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 15 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 16 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 17 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 18 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 19 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= 22 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 23 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 24 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 25 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 27 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 29 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 30 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 31 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 32 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 33 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 34 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 35 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 36 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 37 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 38 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 39 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 40 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 41 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 42 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 43 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 44 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 45 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 46 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 47 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 48 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 49 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 50 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 52 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 53 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 54 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 55 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 56 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 57 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 58 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 61 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 62 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 63 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 64 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 65 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 66 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 67 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 68 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 69 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 70 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 71 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 72 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 73 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 74 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 75 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 76 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 77 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 78 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 79 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 80 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 81 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 82 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 83 | github.com/termkit/skeleton v0.2.2 h1:FmKzoBfl1ZA6HAqigYzWN+GnDA8CXEUDZOHJMCLuBrg= 84 | github.com/termkit/skeleton v0.2.2/go.mod h1:M2KtcgCw/2Ymq9Hy1pHFdrMS7e6LNb19tW6MrNLgLIg= 85 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 86 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 87 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 88 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 89 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 90 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 91 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 92 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 93 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 94 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 95 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 98 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 99 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 100 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 101 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 102 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 103 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 105 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 106 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | type Config struct { 14 | Github Github `mapstructure:"github"` 15 | Shortcuts Shortcuts `mapstructure:"keys"` 16 | Settings Settings `mapstructure:"settings"` 17 | } 18 | 19 | type Settings struct { 20 | LiveMode struct { 21 | Enabled bool `mapstructure:"enabled"` 22 | Interval time.Duration `mapstructure:"interval"` 23 | } `mapstructure:"live_mode"` 24 | } 25 | 26 | type Github struct { 27 | Token string `mapstructure:"token"` 28 | } 29 | 30 | type Shortcuts struct { 31 | SwitchTabRight string `mapstructure:"switch_tab_right"` 32 | SwitchTabLeft string `mapstructure:"switch_tab_left"` 33 | Quit string `mapstructure:"quit"` 34 | Refresh string `mapstructure:"refresh"` 35 | Enter string `mapstructure:"enter"` 36 | LiveMode string `mapstructure:"live_mode"` 37 | Tab string `mapstructure:"tab"` 38 | } 39 | 40 | func LoadConfig() (*Config, error) { 41 | var config = new(Config) 42 | defer func() { 43 | config = fillDefaultShortcuts(config) 44 | config = fillDefaultSettings(config) 45 | }() 46 | 47 | setConfig() 48 | 49 | viper.SetEnvKeyReplacer(strings.NewReplacer(`.`, `_`)) 50 | if err := viper.BindEnv("github.token", "GITHUB_TOKEN"); err != nil { 51 | return nil, fmt.Errorf("failed to bind environment variable: %w", err) 52 | } 53 | viper.AutomaticEnv() 54 | 55 | // Read the config file first 56 | if err := viper.ReadInConfig(); err == nil { 57 | if err := viper.Unmarshal(config); err != nil { 58 | return nil, fmt.Errorf("failed to unmarshal config file: %w", err) 59 | } 60 | return config, nil 61 | } 62 | 63 | // If config file is not found, try to unmarshal from environment variables 64 | if err := viper.Unmarshal(config); err != nil { 65 | return nil, fmt.Errorf("failed to unmarshal config: %w", err) 66 | } 67 | 68 | return config, nil 69 | } 70 | 71 | func setConfig() { 72 | configPath := filepath.Join(os.Getenv("HOME"), ".config", "gama", "config.yaml") 73 | if _, err := os.Stat(configPath); err == nil { 74 | viper.AddConfigPath(filepath.Join(os.Getenv("HOME"), ".config", "gama")) 75 | viper.SetConfigName("config") 76 | viper.SetConfigType("yaml") 77 | return 78 | } 79 | 80 | oldConfigPath := filepath.Join(os.Getenv("HOME"), ".gama.yaml") 81 | if _, err := os.Stat(oldConfigPath); err == nil { 82 | viper.AddConfigPath(os.Getenv("HOME")) 83 | viper.SetConfigName(".gama") 84 | viper.SetConfigType("yaml") 85 | return 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/config/settings.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | func fillDefaultSettings(cfg *Config) *Config { 6 | if cfg.Settings.LiveMode.Interval == time.Duration(0) { 7 | cfg.Settings.LiveMode.Interval = 15 * time.Second 8 | } 9 | 10 | return cfg 11 | } 12 | -------------------------------------------------------------------------------- /internal/config/shortcuts.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | func fillDefaultShortcuts(cfg *Config) *Config { 4 | var switchTabRight = cfg.Shortcuts.SwitchTabRight 5 | if switchTabRight == "" { 6 | switchTabRight = defaultKeyMap.SwitchTabRight 7 | } 8 | var switchTabLeft = cfg.Shortcuts.SwitchTabLeft 9 | if switchTabLeft == "" { 10 | switchTabLeft = defaultKeyMap.SwitchTabLeft 11 | } 12 | var quit = cfg.Shortcuts.Quit 13 | if quit == "" { 14 | quit = defaultKeyMap.Quit 15 | } 16 | var refresh = cfg.Shortcuts.Refresh 17 | if refresh == "" { 18 | refresh = defaultKeyMap.Refresh 19 | } 20 | var enter = cfg.Shortcuts.Enter 21 | if enter == "" { 22 | enter = defaultKeyMap.Enter 23 | } 24 | var tab = cfg.Shortcuts.Tab 25 | if tab == "" { 26 | tab = defaultKeyMap.Tab 27 | } 28 | var liveMode = cfg.Shortcuts.LiveMode 29 | if liveMode == "" { 30 | liveMode = defaultKeyMap.LiveMode 31 | } 32 | cfg.Shortcuts = Shortcuts{ 33 | SwitchTabRight: switchTabRight, 34 | SwitchTabLeft: switchTabLeft, 35 | Quit: quit, 36 | Refresh: refresh, 37 | LiveMode: liveMode, 38 | Enter: enter, 39 | Tab: tab, 40 | } 41 | 42 | return cfg 43 | } 44 | 45 | type defaultMap struct { 46 | SwitchTabRight string 47 | SwitchTabLeft string 48 | Quit string 49 | Refresh string 50 | Enter string 51 | Tab string 52 | LiveMode string 53 | } 54 | 55 | var defaultKeyMap = defaultMap{ 56 | SwitchTabRight: "shift+right", 57 | SwitchTabLeft: "shift+left", 58 | Quit: "ctrl+c", 59 | Refresh: "ctrl+r", 60 | Enter: "enter", 61 | Tab: "tab", 62 | LiveMode: "ctrl+l", 63 | } 64 | -------------------------------------------------------------------------------- /internal/github/domain/domain.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type SortBy string 4 | 5 | const ( 6 | SortByCreated SortBy = "created" 7 | SortByUpdated SortBy = "updated" 8 | SortByPushed SortBy = "pushed" 9 | SortByFullName SortBy = "full_name" 10 | ) 11 | 12 | func (s SortBy) String() string { 13 | return string(s) 14 | } 15 | -------------------------------------------------------------------------------- /internal/github/repository/httpclient.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type HttpClient interface { 8 | Do(req *http.Request) (*http.Response, error) 9 | } 10 | -------------------------------------------------------------------------------- /internal/github/repository/ports.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/termkit/gama/internal/github/domain" 7 | ) 8 | 9 | type Repository interface { 10 | ListRepositories(ctx context.Context, limit int, skip int, sort domain.SortBy) ([]GithubRepository, error) 11 | GetAuthUser(ctx context.Context) (*GithubUser, error) 12 | GetRepository(ctx context.Context, repository string) (*GithubRepository, error) 13 | ListBranches(ctx context.Context, repository string) ([]GithubBranch, error) 14 | ListWorkflowRuns(ctx context.Context, repository string) (*WorkflowRuns, error) 15 | TriggerWorkflow(ctx context.Context, repository string, branch string, workflowName string, workflow any) error 16 | GetWorkflows(ctx context.Context, repository string) ([]Workflow, error) 17 | GetTriggerableWorkflows(ctx context.Context, repository string) ([]Workflow, error) 18 | InspectWorkflowContent(ctx context.Context, repository string, branch string, workflowFile string) ([]byte, error) 19 | ReRunFailedJobs(ctx context.Context, repository string, runId int64) error 20 | ReRunWorkflow(ctx context.Context, repository string, runId int64) error 21 | CancelWorkflow(ctx context.Context, repository string, runId int64) error 22 | } 23 | -------------------------------------------------------------------------------- /internal/github/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "net/url" 12 | "path" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/termkit/gama/internal/config" 18 | 19 | "github.com/termkit/gama/internal/github/domain" 20 | "gopkg.in/yaml.v3" 21 | ) 22 | 23 | type Repo struct { 24 | Client HttpClient 25 | 26 | githubToken string 27 | } 28 | 29 | var githubAPIURL = "https://api.github.com" 30 | 31 | func New(cfg *config.Config) *Repo { 32 | return &Repo{ 33 | Client: &http.Client{ 34 | Timeout: 20 * time.Second, 35 | }, 36 | githubToken: cfg.Github.Token, 37 | } 38 | } 39 | 40 | func (r *Repo) GetAuthUser(ctx context.Context) (*GithubUser, error) { 41 | var githubUser = new(GithubUser) 42 | err := r.do(ctx, nil, githubUser, requestOptions{ 43 | method: http.MethodGet, 44 | paths: []string{"user"}, 45 | }) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return githubUser, nil 51 | } 52 | 53 | func (r *Repo) ListRepositories(ctx context.Context, limit int, page int, sort domain.SortBy) ([]GithubRepository, error) { 54 | resultsChan := make(chan []GithubRepository) 55 | errChan := make(chan error) 56 | 57 | for p := 1; p <= page; p++ { 58 | go r.workerListRepositories(ctx, limit, p, sort, resultsChan, errChan) 59 | } 60 | 61 | var repositories []GithubRepository 62 | var repoErr error 63 | 64 | for range make([]int, page) { 65 | select { 66 | case err := <-errChan: 67 | repoErr = errors.Join(err) 68 | case res := <-resultsChan: 69 | repositories = append(repositories, res...) 70 | } 71 | } 72 | 73 | if repoErr != nil { 74 | return nil, repoErr 75 | } 76 | 77 | return repositories, nil 78 | } 79 | 80 | func (r *Repo) workerListRepositories(ctx context.Context, limit int, page int, sort domain.SortBy, results chan<- []GithubRepository, errs chan<- error) { 81 | var repositories []GithubRepository 82 | err := r.do(ctx, nil, &repositories, requestOptions{ 83 | method: http.MethodGet, 84 | paths: []string{"user", "repos"}, 85 | queryParams: map[string]string{ 86 | "visibility": "all", 87 | "per_page": strconv.Itoa(limit), 88 | "page": strconv.Itoa(page), 89 | "sort": sort.String(), 90 | "direction": "desc", 91 | }, 92 | }) 93 | if err != nil { 94 | errs <- err 95 | return 96 | } 97 | 98 | results <- repositories 99 | } 100 | 101 | func (r *Repo) ListBranches(ctx context.Context, repository string) ([]GithubBranch, error) { 102 | // List branches for the given repository 103 | var branches []GithubBranch 104 | err := r.do(ctx, nil, &branches, requestOptions{ 105 | method: http.MethodGet, 106 | paths: []string{"repos", repository, "branches"}, 107 | }) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | return branches, nil 113 | } 114 | 115 | func (r *Repo) GetRepository(ctx context.Context, repository string) (*GithubRepository, error) { 116 | var repo GithubRepository 117 | err := r.do(ctx, nil, &repo, requestOptions{ 118 | method: http.MethodGet, 119 | paths: []string{"repos", repository}, 120 | }) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | return &repo, nil 126 | } 127 | 128 | func (r *Repo) ListWorkflowRuns(ctx context.Context, repository string) (*WorkflowRuns, error) { 129 | // List workflow runs for the given repository and branch 130 | var workflowRuns WorkflowRuns 131 | err := r.do(ctx, nil, &workflowRuns, requestOptions{ 132 | method: http.MethodGet, 133 | paths: []string{"repos", repository, "actions", "runs"}, 134 | }) 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | return &workflowRuns, nil 140 | } 141 | 142 | func (r *Repo) TriggerWorkflow(ctx context.Context, repository string, branch string, workflowName string, workflow any) error { 143 | var payload = fmt.Sprintf(`{"ref": "%s", "inputs": %s}`, branch, workflow) 144 | 145 | // Trigger a workflow for the given repository and branch 146 | err := r.do(ctx, payload, nil, requestOptions{ 147 | method: http.MethodPost, 148 | paths: []string{"repos", repository, "actions", "workflows", path.Base(workflowName), "dispatches"}, 149 | accept: "application/vnd.github+json", 150 | contentType: "application/vnd.github+json", 151 | }) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (r *Repo) GetWorkflows(ctx context.Context, repository string) ([]Workflow, error) { 160 | // Get a workflow run for the given repository and runID 161 | var githubWorkflow githubWorkflow 162 | err := r.do(ctx, nil, &githubWorkflow, requestOptions{ 163 | method: http.MethodGet, 164 | paths: []string{"repos", repository, "actions", "workflows"}, 165 | }) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | return githubWorkflow.Workflows, nil 171 | } 172 | 173 | func (r *Repo) GetTriggerableWorkflows(ctx context.Context, repository string) ([]Workflow, error) { 174 | // Get a workflow run for the given repository and runID 175 | var workflows githubWorkflow 176 | err := r.do(ctx, nil, &workflows, requestOptions{ 177 | method: http.MethodGet, 178 | paths: []string{"repos", repository, "actions", "workflows"}, 179 | }) 180 | if err != nil { 181 | return nil, err 182 | } 183 | 184 | // Count how many workflows we'll actually process 185 | var validWorkflowCount int 186 | for _, workflow := range workflows.Workflows { 187 | if strings.HasPrefix(workflow.Path, ".github/workflows/") { 188 | validWorkflowCount++ 189 | } 190 | } 191 | 192 | // Create buffered channels only for valid workflows 193 | results := make(chan *Workflow, validWorkflowCount) 194 | errs := make(chan error, validWorkflowCount) 195 | 196 | // Only process workflows with valid paths 197 | for _, workflow := range workflows.Workflows { 198 | if strings.HasPrefix(workflow.Path, ".github/workflows/") { 199 | go r.workerGetTriggerableWorkflows(ctx, repository, workflow, results, errs) 200 | } 201 | } 202 | 203 | // Collect the results and errors 204 | var result []Workflow 205 | var resultErrs []error 206 | for i := 0; i < validWorkflowCount; i++ { 207 | select { 208 | case res := <-results: 209 | if res != nil { 210 | result = append(result, *res) 211 | } 212 | case err := <-errs: 213 | resultErrs = append(resultErrs, err) 214 | } 215 | } 216 | 217 | return result, errors.Join(resultErrs...) 218 | } 219 | 220 | func (r *Repo) workerGetTriggerableWorkflows(ctx context.Context, repository string, workflow Workflow, results chan<- *Workflow, errs chan<- error) { 221 | // Get the workflow file content 222 | fileContent, err := r.getWorkflowFile(ctx, repository, workflow.Path) 223 | if err != nil { 224 | errs <- err 225 | return 226 | } 227 | 228 | // Parse the workflow file content as YAML 229 | var wfFile workflowFile 230 | err = yaml.Unmarshal([]byte(fileContent), &wfFile) 231 | if err != nil { 232 | errs <- err 233 | return 234 | } 235 | 236 | var dispatchWorkflow *Workflow 237 | 238 | // Check if the workflow file content has a "workflow_dispatch" key 239 | if _, ok := wfFile.On["workflow_dispatch"]; ok { 240 | dispatchWorkflow = &workflow 241 | } 242 | 243 | results <- dispatchWorkflow 244 | } 245 | 246 | func (r *Repo) InspectWorkflowContent(ctx context.Context, repository string, branch string, workflowFile string) ([]byte, error) { 247 | // Get the content of the workflow file 248 | var githubFile githubFile 249 | err := r.do(ctx, nil, &githubFile, requestOptions{ 250 | method: http.MethodGet, 251 | paths: []string{"repos", repository, "contents", workflowFile}, 252 | contentType: "application/vnd.github.VERSION.raw", 253 | queryParams: map[string]string{ 254 | "ref": branch, 255 | }, 256 | }) 257 | if err != nil { 258 | return nil, err 259 | } 260 | 261 | // The content is Base64 encoded, so it needs to be decoded 262 | decodedContent, err := base64.StdEncoding.DecodeString(githubFile.Content) 263 | if err != nil { 264 | return nil, err 265 | } 266 | 267 | return decodedContent, nil 268 | } 269 | 270 | func (r *Repo) getWorkflowFile(ctx context.Context, repository string, path string) (string, error) { 271 | // Get the content of the workflow file 272 | var githubFile githubFile 273 | err := r.do(ctx, nil, &githubFile, requestOptions{ 274 | method: http.MethodGet, 275 | paths: []string{"repos", repository, "contents", path}, 276 | contentType: "application/vnd.github.VERSION.raw", 277 | }) 278 | if err != nil { 279 | return "", err 280 | } 281 | 282 | // The content is Base64 encoded, so it needs to be decoded 283 | decodedContent, err := base64.StdEncoding.DecodeString(githubFile.Content) 284 | if err != nil { 285 | return "", err 286 | } 287 | 288 | return string(decodedContent), nil 289 | } 290 | 291 | func (r *Repo) ReRunFailedJobs(ctx context.Context, repository string, runID int64) error { 292 | // Re-run failed jobs for a given workflow run 293 | err := r.do(ctx, nil, nil, requestOptions{ 294 | method: http.MethodPost, 295 | paths: []string{"repos", repository, "actions", "runs", strconv.FormatInt(runID, 10), "rerun-failed-jobs"}, 296 | }) 297 | if err != nil { 298 | return err 299 | } 300 | 301 | return nil 302 | } 303 | 304 | func (r *Repo) ReRunWorkflow(ctx context.Context, repository string, runID int64) error { 305 | // Re-run a given workflow run 306 | err := r.do(ctx, nil, nil, requestOptions{ 307 | method: http.MethodPost, 308 | paths: []string{"repos", repository, "actions", "runs", strconv.FormatInt(runID, 10), "rerun"}, 309 | }) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | return nil 315 | } 316 | 317 | func (r *Repo) CancelWorkflow(ctx context.Context, repository string, runID int64) error { 318 | // Cancel a given workflow run 319 | err := r.do(ctx, nil, nil, requestOptions{ 320 | method: http.MethodPost, 321 | paths: []string{"repos", repository, "actions", "runs", strconv.FormatInt(runID, 10), "cancel"}, 322 | }) 323 | if err != nil { 324 | return err 325 | } 326 | 327 | return nil 328 | } 329 | 330 | func (r *Repo) do(ctx context.Context, requestBody any, responseBody any, requestOptions requestOptions) error { 331 | // Construct the request URL 332 | reqURL, err := joinPath(append([]string{githubAPIURL}, requestOptions.paths...)...) 333 | if err != nil { 334 | return fmt.Errorf("failed to join path for api: %w", err) 335 | } 336 | 337 | // Add query parameters 338 | query := reqURL.Query() 339 | for key, value := range requestOptions.queryParams { 340 | query.Add(key, value) 341 | } 342 | reqURL.RawQuery = query.Encode() 343 | 344 | if requestOptions.contentType == "" { 345 | requestOptions.contentType = "application/json" 346 | } 347 | if requestOptions.accept == "" { 348 | requestOptions.accept = "application/json" 349 | } 350 | 351 | reqBody, err := parseRequestBody(requestOptions, requestBody) 352 | if err != nil { 353 | return err 354 | } 355 | 356 | // Create the HTTP request 357 | req, err := http.NewRequest(requestOptions.method, reqURL.String(), bytes.NewBuffer(reqBody)) 358 | if err != nil { 359 | return err 360 | } 361 | 362 | req.Header.Set("Content-Type", requestOptions.contentType) 363 | req.Header.Set("Accept", requestOptions.accept) 364 | 365 | req.Header.Set("Authorization", "Bearer "+r.githubToken) 366 | req.Header.Set("X-GitHub-Api-Version", "2022-11-28") 367 | req = req.WithContext(ctx) 368 | 369 | // Perform the HTTP request using the injected client 370 | resp, err := r.Client.Do(req) 371 | if err != nil { 372 | return err 373 | } 374 | defer resp.Body.Close() 375 | 376 | var errorResponse struct { 377 | Message string `json:"message"` 378 | } 379 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 380 | // Decode the error response body 381 | err = json.NewDecoder(resp.Body).Decode(&errorResponse) 382 | if err != nil { 383 | return err 384 | } 385 | 386 | return errors.New(errorResponse.Message) 387 | } 388 | 389 | // Decode the response body 390 | if responseBody != nil { 391 | err = json.NewDecoder(resp.Body).Decode(responseBody) 392 | if err != nil { 393 | return err 394 | } 395 | } 396 | 397 | return nil 398 | } 399 | 400 | func parseRequestBody(requestOptions requestOptions, requestBody any) ([]byte, error) { 401 | var reqBody []byte 402 | 403 | if requestBody == nil { 404 | return reqBody, nil 405 | } 406 | 407 | // Marshal the request body to JSON if accept/content type is JSON 408 | if requestOptions.accept == "application/json" || requestOptions.contentType == "application/json" { 409 | reqBody, err := json.Marshal(requestBody) 410 | if err != nil { 411 | return nil, fmt.Errorf("failed to parse request body: %w", err) 412 | } 413 | 414 | return reqBody, nil 415 | } 416 | 417 | reqStr, ok := requestBody.(string) 418 | if !ok { 419 | return nil, fmt.Errorf("failed to convert request body to string: %v", requestBody) 420 | } 421 | 422 | reqBody = []byte(reqStr) 423 | 424 | return reqBody, nil 425 | } 426 | 427 | // joinPath joins URL host and paths with by removing all leading slashes from paths and adds a trailing slash to end of paths except last one. 428 | func joinPath(paths ...string) (*url.URL, error) { 429 | var uri = new(url.URL) 430 | for i, p := range paths { 431 | p = strings.TrimLeft(p, "/") 432 | if i+1 != len(paths) && !strings.HasSuffix(p, "/") { 433 | p = fmt.Sprintf("%s/", p) 434 | } 435 | 436 | u, err := url.Parse(p) 437 | if err != nil { 438 | return nil, err 439 | } 440 | 441 | uri = uri.ResolveReference(u) 442 | } 443 | 444 | return uri, nil 445 | } 446 | 447 | type requestOptions struct { 448 | method string 449 | paths []string 450 | contentType string 451 | accept string 452 | queryParams map[string]string 453 | } 454 | 455 | type githubWorkflow struct { 456 | TotalCount int64 `json:"total_count"` 457 | Workflows []Workflow `json:"workflows"` 458 | } 459 | 460 | type workflowFile struct { 461 | On map[string]any `yaml:"on"` 462 | } 463 | 464 | type githubFile struct { 465 | Content string `json:"content"` 466 | } 467 | -------------------------------------------------------------------------------- /internal/github/repository/repository_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/termkit/gama/internal/config" 9 | 10 | "github.com/termkit/gama/internal/github/domain" 11 | ) 12 | 13 | func newRepo(_ context.Context) *Repo { 14 | cfg, err := config.LoadConfig() 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | repo := New(cfg) 20 | return repo 21 | } 22 | 23 | func TestRepo_ListRepositories(t *testing.T) { 24 | ctx := context.Background() 25 | 26 | repo := newRepo(ctx) 27 | 28 | limit := 30 29 | page := 1 30 | sort := domain.SortByUpdated 31 | 32 | repositories, err := repo.ListRepositories(ctx, limit, page, sort) 33 | if err != nil { 34 | t.Error(err) 35 | } 36 | 37 | if len(repositories) == 0 { 38 | t.Error("Expected repositories, got none") 39 | } 40 | } 41 | 42 | func TestRepo_ListWorkflowRuns(t *testing.T) { 43 | ctx := context.Background() 44 | 45 | repo := newRepo(ctx) 46 | 47 | targetRepositoryName := "canack/tc" 48 | 49 | workflowRuns, err := repo.ListWorkflowRuns(ctx, targetRepositoryName) 50 | if err != nil { 51 | t.Error(err) 52 | } 53 | 54 | t.Log(workflowRuns) 55 | } 56 | 57 | func TestRepo_GetTriggerableWorkflows(t *testing.T) { 58 | ctx := context.Background() 59 | 60 | repo := newRepo(ctx) 61 | 62 | workflows, err := repo.GetTriggerableWorkflows(ctx, "canack/tc") 63 | if err != nil { 64 | t.Error(err) 65 | } 66 | 67 | t.Log(workflows) 68 | } 69 | 70 | func TestRepo_GetAuthUser(t *testing.T) { 71 | type args struct { 72 | ctx context.Context 73 | } 74 | tests := []struct { 75 | name string 76 | args args 77 | want *GithubUser 78 | wantErr bool 79 | }{ 80 | { 81 | name: "correct", 82 | 83 | args: args{ 84 | ctx: context.Background(), 85 | }, 86 | want: &GithubUser{}, 87 | wantErr: false, 88 | }, 89 | } 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | var r = newRepo(tt.args.ctx) 93 | got, err := r.GetAuthUser(tt.args.ctx) 94 | if (err != nil) != tt.wantErr { 95 | t.Errorf("Repo.GetAuthUser() error = %v, wantErr %v", err, tt.wantErr) 96 | return 97 | } 98 | if got.ID == 0 { 99 | t.Errorf("Repo.GetAuthUser() nothing come: %v, wantErr %v", got, tt.wantErr) 100 | return 101 | } 102 | }) 103 | } 104 | } 105 | 106 | func TestRepo_ListBranches(t *testing.T) { 107 | type args struct { 108 | ctx context.Context 109 | repository string 110 | } 111 | tests := []struct { 112 | name string 113 | args args 114 | want []GithubBranch 115 | wantErr bool 116 | }{ 117 | { 118 | name: "correct", 119 | args: args{ 120 | ctx: context.Background(), 121 | repository: "fleimkeipa/dvpwa", // public repo 122 | }, 123 | want: []GithubBranch{ 124 | { 125 | Name: "develop", 126 | }, 127 | { 128 | Name: "imported", 129 | }, 130 | { 131 | Name: "main", 132 | }, 133 | { 134 | Name: "master", 135 | }, 136 | }, 137 | wantErr: false, 138 | }, 139 | } 140 | for _, tt := range tests { 141 | t.Run(tt.name, func(t *testing.T) { 142 | var r = newRepo(tt.args.ctx) 143 | got, err := r.ListBranches(tt.args.ctx, tt.args.repository) 144 | if (err != nil) != tt.wantErr { 145 | t.Errorf("Repo.ListBranches() error = %v, wantErr %v", err, tt.wantErr) 146 | return 147 | } 148 | if !reflect.DeepEqual(got, tt.want) { 149 | t.Errorf("Repo.ListBranches() = %v, want %v", got, tt.want) 150 | } 151 | }) 152 | } 153 | } 154 | 155 | func TestRepo_GetRepository(t *testing.T) { 156 | type args struct { 157 | ctx context.Context 158 | repository string 159 | } 160 | tests := []struct { 161 | name string 162 | args args 163 | want *GithubRepository 164 | wantErr bool 165 | }{ 166 | { 167 | name: "correct", 168 | args: args{ 169 | ctx: context.Background(), 170 | repository: "fleimkeipa/dvpwa", // public repo 171 | }, 172 | want: &GithubRepository{ 173 | Name: "dvpwa", 174 | }, 175 | wantErr: false, 176 | }, 177 | } 178 | for _, tt := range tests { 179 | t.Run(tt.name, func(t *testing.T) { 180 | var r = newRepo(tt.args.ctx) 181 | got, err := r.GetRepository(tt.args.ctx, tt.args.repository) 182 | if (err != nil) != tt.wantErr { 183 | t.Errorf("Repo.GetRepository() error = %v, wantErr %v", err, tt.wantErr) 184 | return 185 | } 186 | if !reflect.DeepEqual(got.Name, tt.want.Name) { 187 | t.Errorf("Repo.GetRepository() = %v, want %v", got, tt.want) 188 | } 189 | }) 190 | } 191 | } 192 | 193 | func TestRepo_GetWorkflows(t *testing.T) { 194 | type args struct { 195 | ctx context.Context 196 | repository string 197 | } 198 | tests := []struct { 199 | name string 200 | args args 201 | want []Workflow 202 | wantErr bool 203 | }{ 204 | { 205 | name: "correct", 206 | args: args{ 207 | ctx: context.Background(), 208 | repository: "fleimkeipa/dvpwa", // public repo 209 | }, 210 | want: []Workflow{}, 211 | wantErr: false, 212 | }, 213 | } 214 | for _, tt := range tests { 215 | t.Run(tt.name, func(t *testing.T) { 216 | var r = newRepo(tt.args.ctx) 217 | got, err := r.GetWorkflows(tt.args.ctx, tt.args.repository) 218 | if (err != nil) != tt.wantErr { 219 | t.Errorf("Repo.GetWorkflows() error = %v, wantErr %v", err, tt.wantErr) 220 | return 221 | } 222 | if len(got) == 0 { 223 | t.Errorf("Repo.GetWorkflows() = %v, want %v", len(got), 2) 224 | } 225 | }) 226 | } 227 | } 228 | 229 | func TestRepo_InspectWorkflowContent(t *testing.T) { 230 | type args struct { 231 | ctx context.Context 232 | repository string 233 | branch string 234 | workflowFile string 235 | } 236 | tests := []struct { 237 | name string 238 | args args 239 | want []byte 240 | wantErr bool 241 | }{ 242 | { 243 | name: "correct", 244 | args: args{ 245 | ctx: context.Background(), 246 | repository: "fleimkeipa/dvpwa", 247 | branch: "master", 248 | workflowFile: ".github/workflows/build.yml", 249 | }, 250 | want: []byte{}, 251 | wantErr: false, 252 | }, 253 | } 254 | for _, tt := range tests { 255 | t.Run(tt.name, func(t *testing.T) { 256 | var r = newRepo(tt.args.ctx) 257 | got, err := r.InspectWorkflowContent(tt.args.ctx, tt.args.repository, tt.args.branch, tt.args.workflowFile) 258 | if (err != nil) != tt.wantErr { 259 | t.Errorf("Repo.InspectWorkflowContent() error = %v, wantErr %v", err, tt.wantErr) 260 | return 261 | } 262 | if len(got) == 0 { 263 | t.Errorf("Repo.GetWorkflows() = %v, want %v", len(got), 2) 264 | } 265 | }) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /internal/github/repository/types.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type GithubRepository struct { 8 | Id int `json:"id"` 9 | NodeId string `json:"node_id"` 10 | Name string `json:"name"` 11 | FullName string `json:"full_name"` 12 | Private bool `json:"private"` 13 | Description string `json:"description"` 14 | Language any `json:"language"` 15 | ForksCount int `json:"forks_count"` 16 | StargazersCount int `json:"stargazers_count"` 17 | WatchersCount int `json:"watchers_count"` 18 | Size int `json:"size"` 19 | DefaultBranch string `json:"default_branch"` 20 | OpenIssuesCount int `json:"open_issues_count"` 21 | IsTemplate bool `json:"is_template"` 22 | Topics []string `json:"topics"` 23 | HasIssues bool `json:"has_issues"` 24 | HasProjects bool `json:"has_projects"` 25 | HasWiki bool `json:"has_wiki"` 26 | HasPages bool `json:"has_pages"` 27 | HasDownloads bool `json:"has_downloads"` 28 | Archived bool `json:"archived"` 29 | Disabled bool `json:"disabled"` 30 | Visibility string `json:"visibility"` 31 | PushedAt time.Time `json:"pushed_at"` 32 | CreatedAt time.Time `json:"created_at"` 33 | UpdatedAt time.Time `json:"updated_at"` 34 | Permissions struct { 35 | Admin bool `json:"admin"` 36 | Push bool `json:"push"` 37 | Pull bool `json:"pull"` 38 | } `json:"permissions"` 39 | AllowRebaseMerge bool `json:"allow_rebase_merge"` 40 | TemplateRepository any `json:"template_repository"` 41 | TempCloneToken string `json:"temp_clone_token"` 42 | AllowSquashMerge bool `json:"allow_squash_merge"` 43 | AllowAutoMerge bool `json:"allow_auto_merge"` 44 | DeleteBranchOnMerge bool `json:"delete_branch_on_merge"` 45 | AllowMergeCommit bool `json:"allow_merge_commit"` 46 | SubscribersCount int `json:"subscribers_count"` 47 | NetworkCount int `json:"network_count"` 48 | License struct { 49 | Key string `json:"key"` 50 | Name string `json:"name"` 51 | Url string `json:"url"` 52 | SpdxId string `json:"spdx_id"` 53 | NodeId string `json:"node_id"` 54 | HtmlUrl string `json:"html_url"` 55 | } `json:"license"` 56 | Forks int `json:"forks"` 57 | OpenIssues int `json:"open_issues"` 58 | Watchers int `json:"watchers"` 59 | } 60 | 61 | type GithubBranch struct { 62 | Name string `json:"name"` 63 | } 64 | 65 | type Workflow struct { 66 | ID int64 `json:"id"` 67 | Name string `json:"name"` 68 | Path string `json:"path"` 69 | State string `json:"state"` 70 | UpdatedAt time.Time `json:"updated_at"` 71 | Url string `json:"url"` 72 | HtmlUrl string `json:"html_url"` 73 | } 74 | 75 | type WorkflowRuns struct { 76 | TotalCount int64 `json:"total_count"` 77 | WorkflowRuns []WorkflowRun `json:"workflow_runs"` 78 | } 79 | 80 | type WorkflowRun struct { 81 | ID int64 `json:"id"` 82 | WorkflowID int64 `json:"workflow_id"` 83 | Name string `json:"name"` 84 | DisplayTitle string `json:"display_title"` 85 | Actor Actor `json:"actor"` 86 | TriggeringActor Actor `json:"triggering_actor"` 87 | Status string `json:"status"` 88 | CreatedAt time.Time `json:"created_at"` 89 | UpdatedAt time.Time `json:"updated_at"` 90 | Conclusion string `json:"conclusion"` 91 | HeadBranch string `json:"head_branch"` 92 | 93 | RunAttempt int `json:"run_attempt"` 94 | CheckSuiteURL string `json:"check_suite_url"` 95 | CancelURL string `json:"cancel_url"` 96 | RerunURL string `json:"rerun_url"` 97 | Path string `json:"path"` 98 | Event string `json:"event"` 99 | HTMLURL string `json:"html_url"` 100 | LogsURL string `json:"logs_url"` 101 | JobsURL string `json:"jobs_url"` 102 | ArtifactsURL string `json:"artifacts_url"` 103 | } 104 | 105 | type Actor struct { 106 | Id int64 `json:"id"` 107 | Login string `json:"login"` 108 | AvatarUrl string `json:"avatar_url"` 109 | } 110 | 111 | type GithubUser struct { 112 | Login string `json:"login"` // username 113 | ID int `json:"id"` 114 | Email string `json:"email"` 115 | } 116 | -------------------------------------------------------------------------------- /internal/github/usecase/ports.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type UseCase interface { 8 | GetAuthUser(ctx context.Context) (*GetAuthUserOutput, error) 9 | ListRepositories(ctx context.Context, input ListRepositoriesInput) (*ListRepositoriesOutput, error) 10 | GetRepositoryBranches(ctx context.Context, input GetRepositoryBranchesInput) (*GetRepositoryBranchesOutput, error) 11 | GetWorkflowHistory(ctx context.Context, input GetWorkflowHistoryInput) (*GetWorkflowHistoryOutput, error) 12 | GetTriggerableWorkflows(ctx context.Context, input GetTriggerableWorkflowsInput) (*GetTriggerableWorkflowsOutput, error) 13 | InspectWorkflow(ctx context.Context, input InspectWorkflowInput) (*InspectWorkflowOutput, error) 14 | TriggerWorkflow(ctx context.Context, input TriggerWorkflowInput) error 15 | ReRunFailedJobs(ctx context.Context, input ReRunFailedJobsInput) error 16 | ReRunWorkflow(ctx context.Context, input ReRunWorkflowInput) error 17 | CancelWorkflow(ctx context.Context, input CancelWorkflowInput) error 18 | } 19 | -------------------------------------------------------------------------------- /internal/github/usecase/types.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/termkit/gama/internal/github/domain" 7 | pw "github.com/termkit/gama/pkg/workflow" 8 | ) 9 | 10 | type ListRepositoriesInput struct { 11 | Limit int 12 | Page int 13 | Sort domain.SortBy 14 | } 15 | 16 | func (i *ListRepositoriesInput) Prepare() { 17 | if i.Limit <= 0 { 18 | i.Limit = 500 19 | } 20 | 21 | if i.Page <= 0 { 22 | i.Page = 1 23 | } 24 | 25 | if i.Sort == "" { 26 | i.Sort = domain.SortByPushed 27 | } 28 | } 29 | 30 | type ListRepositoriesOutput struct { 31 | Repositories []GithubRepository 32 | } 33 | 34 | // ------------------------------------------------------------ 35 | 36 | type GetRepositoryBranchesInput struct { 37 | Repository string 38 | } 39 | 40 | type GetRepositoryBranchesOutput struct { 41 | Branches []GithubBranch 42 | } 43 | 44 | type GithubBranch struct { 45 | Name string 46 | IsDefault bool 47 | } 48 | 49 | // ------------------------------------------------------------ 50 | 51 | type GithubRepository struct { 52 | Name string 53 | Private bool 54 | DefaultBranch string 55 | Stars int 56 | LastUpdated time.Time 57 | 58 | Workflows []Workflow 59 | // We can add more fields here 60 | } 61 | 62 | // ------------------------------------------------------------ 63 | 64 | type GetAuthUserOutput struct { 65 | GithubUser 66 | } 67 | 68 | type GithubUser struct { 69 | Login string `json:"login"` // username 70 | ID int `json:"id"` 71 | Email string `json:"email"` 72 | } 73 | 74 | // ------------------------------------------------------------ 75 | 76 | type GetWorkflowHistoryInput struct { 77 | Repository string 78 | Branch string 79 | } 80 | 81 | type GetWorkflowHistoryOutput struct { 82 | Workflows []Workflow 83 | } 84 | 85 | type Workflow struct { 86 | ID int64 // workflow id 87 | WorkflowName string // workflow name 88 | ActionName string // commit message 89 | TriggeredBy string // who triggered this workflow 90 | StartedAt string // workflow's started at 91 | Status string // workflow's status, like success, failure, etc. 92 | Conclusion string // workflow's conclusion, like success, failure, etc. 93 | Duration string // workflow's duration 94 | } 95 | 96 | // ------------------------------------------------------------ 97 | 98 | type InspectWorkflowInput struct { 99 | Repository string 100 | Branch string 101 | WorkflowFile string 102 | } 103 | 104 | type InspectWorkflowOutput struct { 105 | Workflow *pw.Pretty 106 | } 107 | 108 | // ------------------------------------------------------------ 109 | 110 | type TriggerWorkflowInput struct { 111 | WorkflowFile string 112 | Repository string 113 | Branch string 114 | Content string // workflow content in json format 115 | } 116 | 117 | // ------------------------------------------------------------ 118 | 119 | type GetTriggerableWorkflowsInput struct { 120 | Repository string 121 | Branch string 122 | } 123 | 124 | type GetTriggerableWorkflowsOutput struct { 125 | TriggerableWorkflows []TriggerableWorkflow 126 | } 127 | 128 | type TriggerableWorkflow struct { 129 | ID int64 130 | Name string 131 | Path string 132 | } 133 | 134 | // ------------------------------------------------------------ 135 | 136 | type ReRunFailedJobsInput struct { 137 | Repository string 138 | WorkflowID int64 139 | } 140 | 141 | // ------------------------------------------------------------ 142 | 143 | type ReRunWorkflowInput struct { 144 | Repository string 145 | WorkflowID int64 146 | } 147 | 148 | // ------------------------------------------------------------ 149 | 150 | type CancelWorkflowInput struct { 151 | Repository string 152 | WorkflowID int64 153 | } 154 | -------------------------------------------------------------------------------- /internal/github/usecase/usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "slices" 8 | "time" 9 | 10 | gr "github.com/termkit/gama/internal/github/repository" 11 | pw "github.com/termkit/gama/pkg/workflow" 12 | py "github.com/termkit/gama/pkg/yaml" 13 | ) 14 | 15 | type useCase struct { 16 | githubRepository gr.Repository 17 | } 18 | 19 | func New(githubRepository gr.Repository) UseCase { 20 | return &useCase{ 21 | githubRepository: githubRepository, 22 | } 23 | } 24 | 25 | func (u useCase) GetAuthUser(ctx context.Context) (*GetAuthUserOutput, error) { 26 | authUser, err := u.githubRepository.GetAuthUser(ctx) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return &GetAuthUserOutput{ 32 | GithubUser: GithubUser{ 33 | Login: authUser.Login, 34 | ID: authUser.ID, 35 | Email: authUser.Email, 36 | }, 37 | }, nil 38 | } 39 | 40 | func (u useCase) ListRepositories(ctx context.Context, input ListRepositoriesInput) (*ListRepositoriesOutput, error) { 41 | input.Prepare() 42 | 43 | repositories, err := u.githubRepository.ListRepositories(ctx, input.Limit, input.Page, input.Sort) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Create a buffered channel for results and errors 49 | results := make(chan GithubRepository, len(repositories)) 50 | errs := make(chan error, len(repositories)) 51 | 52 | // Send jobs to the workers 53 | for _, repository := range repositories { 54 | go u.workerListRepositories(ctx, repository, results, errs) 55 | } 56 | 57 | // Collect the results and errors 58 | var result []GithubRepository 59 | var resultErrs []error 60 | for range repositories { 61 | select { 62 | case res := <-results: 63 | result = append(result, res) 64 | case err := <-errs: 65 | resultErrs = append(resultErrs, err) 66 | } 67 | } 68 | 69 | slices.SortFunc(result, func(a, b GithubRepository) int { 70 | return int(b.LastUpdated.Unix() - a.LastUpdated.Unix()) 71 | }) 72 | 73 | return &ListRepositoriesOutput{ 74 | Repositories: result, 75 | }, errors.Join(resultErrs...) 76 | } 77 | 78 | func (u useCase) GetRepositoryBranches(ctx context.Context, input GetRepositoryBranchesInput) (*GetRepositoryBranchesOutput, error) { 79 | // Get Repository to get the default branch 80 | repository, err := u.githubRepository.GetRepository(ctx, input.Repository) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | var mainBranch = repository.DefaultBranch 86 | 87 | branches, err := u.githubRepository.ListBranches(ctx, input.Repository) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | if len(branches) == 0 { 93 | return &GetRepositoryBranchesOutput{}, nil 94 | } 95 | 96 | var result = []GithubBranch{ 97 | { 98 | Name: mainBranch, 99 | IsDefault: true, 100 | }, 101 | } 102 | 103 | for _, branch := range branches { 104 | result = append(result, GithubBranch{ 105 | Name: branch.Name, 106 | }) 107 | } 108 | 109 | return &GetRepositoryBranchesOutput{ 110 | Branches: result, 111 | }, nil 112 | } 113 | 114 | func (u useCase) workerListRepositories(ctx context.Context, repository gr.GithubRepository, results chan<- GithubRepository, errs chan<- error) { 115 | getWorkflows, err := u.githubRepository.GetWorkflows(ctx, repository.FullName) 116 | if err != nil { 117 | errs <- err 118 | return 119 | } 120 | 121 | var workflows []Workflow 122 | for _, workflow := range getWorkflows { 123 | workflows = append(workflows, Workflow{ 124 | ID: workflow.ID, 125 | }) 126 | } 127 | 128 | results <- GithubRepository{ 129 | Name: repository.FullName, 130 | Stars: repository.StargazersCount, 131 | Private: repository.Private, 132 | DefaultBranch: repository.DefaultBranch, 133 | LastUpdated: repository.UpdatedAt, 134 | Workflows: workflows, 135 | } 136 | } 137 | 138 | func (u useCase) GetWorkflowHistory(ctx context.Context, input GetWorkflowHistoryInput) (*GetWorkflowHistoryOutput, error) { 139 | var targetRepositoryName = input.Repository 140 | 141 | workflowRuns, err := u.githubRepository.ListWorkflowRuns(ctx, targetRepositoryName) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | var workflows []Workflow 147 | for _, workflowRun := range workflowRuns.WorkflowRuns { 148 | workflows = append(workflows, Workflow{ 149 | ID: workflowRun.ID, 150 | WorkflowName: workflowRun.Name, 151 | ActionName: workflowRun.DisplayTitle, 152 | TriggeredBy: workflowRun.Actor.Login, 153 | StartedAt: u.timeToString(workflowRun.CreatedAt), 154 | Status: workflowRun.Status, 155 | Conclusion: workflowRun.Conclusion, 156 | Duration: u.getDuration(workflowRun.CreatedAt, workflowRun.UpdatedAt, workflowRun.Status), 157 | }) 158 | } 159 | 160 | return &GetWorkflowHistoryOutput{ 161 | Workflows: workflows, 162 | }, nil 163 | } 164 | 165 | func (u useCase) GetTriggerableWorkflows(ctx context.Context, input GetTriggerableWorkflowsInput) (*GetTriggerableWorkflowsOutput, error) { 166 | // TODO: Add branch option 167 | triggerableWorkflows, err := u.githubRepository.GetTriggerableWorkflows(ctx, input.Repository) 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | var workflows []TriggerableWorkflow 173 | for _, workflow := range triggerableWorkflows { 174 | workflows = append(workflows, TriggerableWorkflow{ 175 | ID: workflow.ID, 176 | Name: workflow.Name, 177 | Path: workflow.Path, 178 | }) 179 | } 180 | 181 | return &GetTriggerableWorkflowsOutput{ 182 | TriggerableWorkflows: workflows, 183 | }, nil 184 | } 185 | 186 | func (u useCase) InspectWorkflow(ctx context.Context, input InspectWorkflowInput) (*InspectWorkflowOutput, error) { 187 | workflowData, err := u.githubRepository.InspectWorkflowContent(ctx, input.Repository, input.Branch, input.WorkflowFile) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | workflowContent, err := py.UnmarshalWorkflowContent(workflowData) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | workflow, err := pw.ParseWorkflow(*workflowContent) 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | pretty := workflow.ToPretty() 203 | 204 | return &InspectWorkflowOutput{ 205 | Workflow: pretty, 206 | }, nil 207 | } 208 | 209 | func (u useCase) TriggerWorkflow(ctx context.Context, input TriggerWorkflowInput) error { 210 | return u.githubRepository.TriggerWorkflow(ctx, input.Repository, input.Branch, input.WorkflowFile, input.Content) 211 | } 212 | 213 | func (u useCase) ReRunFailedJobs(ctx context.Context, input ReRunFailedJobsInput) error { 214 | return u.githubRepository.ReRunFailedJobs(ctx, input.Repository, input.WorkflowID) 215 | } 216 | 217 | func (u useCase) ReRunWorkflow(ctx context.Context, input ReRunWorkflowInput) error { 218 | return u.githubRepository.ReRunWorkflow(ctx, input.Repository, input.WorkflowID) 219 | } 220 | 221 | func (u useCase) CancelWorkflow(ctx context.Context, input CancelWorkflowInput) error { 222 | return u.githubRepository.CancelWorkflow(ctx, input.Repository, input.WorkflowID) 223 | } 224 | 225 | func (u useCase) timeToString(t time.Time) string { 226 | return t.In(time.Local).Format("2006-01-02 15:04:05") 227 | } 228 | 229 | func (u useCase) getDuration(startTime time.Time, endTime time.Time, status string) string { 230 | // Convert UTC times to local timezone 231 | localStartTime := startTime.In(time.Local) 232 | localEndTime := endTime.In(time.Local) 233 | 234 | var diff time.Duration 235 | 236 | if status != "completed" { 237 | diff = time.Since(localStartTime) 238 | } else { 239 | diff = localEndTime.Sub(localStartTime) 240 | } 241 | 242 | switch { 243 | case diff.Seconds() < 60: 244 | return fmt.Sprintf("%ds", int(diff.Seconds())) 245 | case diff.Seconds() < 3600: 246 | return fmt.Sprintf("%dm %ds", int(diff.Minutes()), int(diff.Seconds())%60) 247 | default: 248 | return fmt.Sprintf("%dh %dm %ds", int(diff.Hours()), int(diff.Minutes())%60, int(diff.Seconds())%60) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /internal/github/usecase/usecase_test.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/termkit/gama/internal/config" 8 | 9 | "github.com/termkit/gama/internal/github/domain" 10 | "github.com/termkit/gama/internal/github/repository" 11 | ) 12 | 13 | func TestUseCase_ListRepositories(t *testing.T) { 14 | ctx := context.Background() 15 | cfg, err := config.LoadConfig() 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | githubRepo := repository.New(cfg) 21 | 22 | githubUseCase := New(githubRepo) 23 | 24 | repositories, err := githubUseCase.ListRepositories(ctx, ListRepositoriesInput{ 25 | Limit: 10, 26 | Page: 0, 27 | Sort: domain.SortByCreated, 28 | }) 29 | 30 | if err != nil { 31 | t.Error(err) 32 | } 33 | t.Log(repositories) 34 | } 35 | 36 | func TestUseCase_InspectWorkflow(t *testing.T) { 37 | ctx := context.Background() 38 | cfg, err := config.LoadConfig() 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | githubRepo := repository.New(cfg) 44 | 45 | githubUseCase := New(githubRepo) 46 | 47 | workflow, err := githubUseCase.InspectWorkflow(ctx, InspectWorkflowInput{ 48 | Repository: "canack/tc", 49 | WorkflowFile: ".github/workflows/dispatch_test.yaml", 50 | }) 51 | if err != nil { 52 | t.Error(err) 53 | } 54 | t.Log(workflow) 55 | } 56 | 57 | func TestUseCase_TriggerWorkflow(t *testing.T) { 58 | ctx := context.Background() 59 | cfg, err := config.LoadConfig() 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | githubRepo := repository.New(cfg) 65 | 66 | githubUseCase := New(githubRepo) 67 | 68 | workflow, err := githubUseCase.InspectWorkflow(ctx, InspectWorkflowInput{ 69 | Repository: "canack/tc", 70 | WorkflowFile: ".github/workflows/dispatch_test.yaml", 71 | }) 72 | if err != nil { 73 | t.Error(err) 74 | } 75 | 76 | for i, w := range workflow.Workflow.Inputs { 77 | if w.Key == "go-version" { 78 | w.SetValue("2.0") 79 | workflow.Workflow.Inputs[i] = w 80 | } 81 | } 82 | 83 | workflowJson, err := workflow.Workflow.ToJson() 84 | if err != nil { 85 | t.Error(err) 86 | } 87 | 88 | err = githubUseCase.TriggerWorkflow(ctx, TriggerWorkflowInput{ 89 | WorkflowFile: ".github/workflows/dispatch_test.yaml", 90 | Repository: "canack/tc", 91 | Branch: "master", 92 | Content: workflowJson, 93 | }) 94 | if err != nil { 95 | t.Error(err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/terminal/handler/ghinformation.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | gu "github.com/termkit/gama/internal/github/usecase" 13 | pkgversion "github.com/termkit/gama/pkg/version" 14 | "github.com/termkit/skeleton" 15 | ) 16 | 17 | // ----------------------------------------------------------------------------- 18 | // Model Definition 19 | // ----------------------------------------------------------------------------- 20 | 21 | type ModelInfo struct { 22 | // Core dependencies 23 | skeleton *skeleton.Skeleton 24 | github gu.UseCase 25 | version pkgversion.Version 26 | 27 | // UI Components 28 | help help.Model 29 | status *ModelStatus 30 | keys githubInformationKeyMap 31 | 32 | // Application state 33 | logo string 34 | releaseURL string 35 | applicationDescription string 36 | newVersionAvailableMsg string 37 | } 38 | 39 | // ----------------------------------------------------------------------------- 40 | // Constructor & Initialization 41 | // ----------------------------------------------------------------------------- 42 | 43 | func SetupModelInfo(s *skeleton.Skeleton, githubUseCase gu.UseCase, version pkgversion.Version) *ModelInfo { 44 | const releaseURL = "https://github.com/termkit/gama/releases" 45 | 46 | modelStatus := SetupModelStatus(s) 47 | 48 | return &ModelInfo{ 49 | // Initialize core dependencies 50 | skeleton: s, 51 | github: githubUseCase, 52 | version: version, 53 | 54 | // Initialize UI components 55 | help: help.New(), 56 | status: modelStatus, 57 | keys: githubInformationKeys, 58 | 59 | // Initialize application state 60 | logo: defaultLogo, 61 | releaseURL: releaseURL, 62 | } 63 | } 64 | 65 | const defaultLogo = ` 66 | ..|'''.| | '|| ||' | 67 | .|' ' ||| ||| ||| ||| 68 | || .... | || |'|..'|| | || 69 | '|. || .''''|. | '|' || .''''|. 70 | ''|...'| .|. .||. .|. | .||. .|. .||. 71 | ` 72 | 73 | // ----------------------------------------------------------------------------- 74 | // Bubbletea Model Implementation 75 | // ----------------------------------------------------------------------------- 76 | 77 | func (m *ModelInfo) Init() tea.Cmd { 78 | m.initializeAppDescription() 79 | m.startBackgroundTasks() 80 | 81 | return tea.Batch( 82 | tea.EnterAltScreen, 83 | tea.SetWindowTitle("GitHub Actions Manager (GAMA)"), 84 | ) 85 | } 86 | 87 | func (m *ModelInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 88 | switch msg := msg.(type) { 89 | case tea.KeyMsg: 90 | switch { 91 | case key.Matches(msg, m.keys.Quit): 92 | return m, tea.Quit 93 | } 94 | } 95 | return m, nil 96 | } 97 | 98 | func (m *ModelInfo) View() string { 99 | return lipgloss.JoinVertical(lipgloss.Center, 100 | m.renderMainContent(), 101 | m.status.View(), 102 | m.renderHelpWindow(), 103 | ) 104 | } 105 | 106 | // ----------------------------------------------------------------------------- 107 | // UI Rendering 108 | // ----------------------------------------------------------------------------- 109 | 110 | func (m *ModelInfo) renderMainContent() string { 111 | content := strings.Builder{} 112 | 113 | // Add vertical centering 114 | centerPadding := m.calculateCenterPadding() 115 | content.WriteString(strings.Repeat("\n", centerPadding)) 116 | 117 | // Add main content 118 | content.WriteString(lipgloss.JoinVertical(lipgloss.Center, 119 | m.logo, 120 | m.applicationDescription, 121 | m.newVersionAvailableMsg, 122 | )) 123 | 124 | // Add bottom padding 125 | bottomPadding := m.calculateBottomPadding(content.String()) 126 | content.WriteString(strings.Repeat("\n", bottomPadding)) 127 | 128 | return content.String() 129 | } 130 | 131 | func (m *ModelInfo) renderHelpWindow() string { 132 | helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) 133 | return helpStyle.Render(m.ViewHelp()) 134 | } 135 | 136 | // ----------------------------------------------------------------------------- 137 | // Layout Calculations 138 | // ----------------------------------------------------------------------------- 139 | 140 | func (m *ModelInfo) calculateCenterPadding() int { 141 | padding := m.skeleton.GetTerminalHeight()/2 - 11 142 | return max(0, padding) 143 | } 144 | 145 | func (m *ModelInfo) calculateBottomPadding(content string) int { 146 | padding := m.skeleton.GetTerminalHeight() - lipgloss.Height(content) - 12 147 | return max(0, padding) 148 | } 149 | 150 | // ----------------------------------------------------------------------------- 151 | // Application State Management 152 | // ----------------------------------------------------------------------------- 153 | 154 | func (m *ModelInfo) initializeAppDescription() { 155 | m.applicationDescription = fmt.Sprintf("Github Actions Manager (%s)", m.version.CurrentVersion()) 156 | } 157 | 158 | func (m *ModelInfo) startBackgroundTasks() { 159 | go m.checkUpdates(context.Background()) 160 | go m.testConnection(context.Background()) 161 | } 162 | 163 | // ----------------------------------------------------------------------------- 164 | // Background Tasks 165 | // ----------------------------------------------------------------------------- 166 | 167 | func (m *ModelInfo) checkUpdates(ctx context.Context) { 168 | defer m.skeleton.TriggerUpdate() 169 | 170 | isUpdateAvailable, version, err := m.version.IsUpdateAvailable(ctx) 171 | if err != nil { 172 | m.handleUpdateError(err) 173 | return 174 | } 175 | 176 | if isUpdateAvailable { 177 | m.newVersionAvailableMsg = fmt.Sprintf( 178 | "New version available: %s\nPlease visit: %s", 179 | version, 180 | m.releaseURL, 181 | ) 182 | } 183 | } 184 | 185 | func (m *ModelInfo) testConnection(ctx context.Context) { 186 | defer m.skeleton.TriggerUpdate() 187 | 188 | m.status.SetProgressMessage("Checking your token...") 189 | m.skeleton.LockTabs() 190 | 191 | _, err := m.github.GetAuthUser(ctx) 192 | if err != nil { 193 | m.handleConnectionError(err) 194 | return 195 | } 196 | 197 | m.handleSuccessfulConnection() 198 | } 199 | 200 | // ----------------------------------------------------------------------------- 201 | // Error Handling 202 | // ----------------------------------------------------------------------------- 203 | 204 | func (m *ModelInfo) handleUpdateError(err error) { 205 | m.status.SetError(err) 206 | m.status.SetErrorMessage("failed to check updates") 207 | m.newVersionAvailableMsg = fmt.Sprintf( 208 | "failed to check updates.\nPlease visit: %s", 209 | m.releaseURL, 210 | ) 211 | } 212 | 213 | func (m *ModelInfo) handleConnectionError(err error) { 214 | m.status.SetError(err) 215 | m.status.SetErrorMessage("failed to test connection, please check your token&permission") 216 | m.skeleton.LockTabs() 217 | } 218 | 219 | func (m *ModelInfo) handleSuccessfulConnection() { 220 | m.status.Reset() 221 | m.status.SetSuccessMessage("Welcome to GAMA!") 222 | m.skeleton.UnlockTabs() 223 | } 224 | -------------------------------------------------------------------------------- /internal/terminal/handler/ghrepository.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/bubbles/help" 12 | "github.com/charmbracelet/bubbles/key" 13 | "github.com/charmbracelet/bubbles/table" 14 | "github.com/charmbracelet/bubbles/textinput" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | "github.com/termkit/gama/internal/github/domain" 18 | gu "github.com/termkit/gama/internal/github/usecase" 19 | "github.com/termkit/gama/pkg/browser" 20 | "github.com/termkit/skeleton" 21 | ) 22 | 23 | // ----------------------------------------------------------------------------- 24 | // Model Definition 25 | // ----------------------------------------------------------------------------- 26 | 27 | type ModelGithubRepository struct { 28 | // Core dependencies 29 | skeleton *skeleton.Skeleton 30 | github gu.UseCase 31 | 32 | // UI State 33 | tableReady bool 34 | 35 | // Context management 36 | syncRepositoriesContext context.Context 37 | cancelSyncRepositories context.CancelFunc 38 | 39 | // Shared state 40 | selectedRepository *SelectedRepository 41 | 42 | // UI Components 43 | help help.Model 44 | Keys githubRepositoryKeyMap 45 | tableGithubRepository table.Model 46 | searchTableGithubRepository table.Model 47 | status *ModelStatus 48 | textInput textinput.Model 49 | modelTabOptions *ModelTabOptions 50 | } 51 | 52 | // ----------------------------------------------------------------------------- 53 | // Constructor & Initialization 54 | // ----------------------------------------------------------------------------- 55 | 56 | func SetupModelGithubRepository(s *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubRepository { 57 | modelStatus := SetupModelStatus(s) 58 | tabOptions := NewOptions(s, modelStatus) 59 | 60 | m := &ModelGithubRepository{ 61 | // Initialize core dependencies 62 | skeleton: s, 63 | github: githubUseCase, 64 | 65 | // Initialize UI components 66 | help: help.New(), 67 | Keys: githubRepositoryKeys, 68 | status: modelStatus, 69 | textInput: setupTextInput(), 70 | modelTabOptions: tabOptions, 71 | 72 | // Initialize state 73 | selectedRepository: NewSelectedRepository(), 74 | syncRepositoriesContext: context.Background(), 75 | cancelSyncRepositories: func() {}, 76 | } 77 | 78 | // Setup tables 79 | m.tableGithubRepository = setupMainTable() 80 | m.searchTableGithubRepository = setupSearchTable() 81 | 82 | return m 83 | } 84 | 85 | func setupMainTable() table.Model { 86 | t := table.New( 87 | table.WithColumns(tableColumnsGithubRepository), 88 | table.WithRows([]table.Row{}), 89 | table.WithFocused(true), 90 | table.WithHeight(13), 91 | ) 92 | 93 | // Apply styles 94 | t.SetStyles(defaultTableStyles()) 95 | 96 | // Apply keymap 97 | t.KeyMap = defaultTableKeyMap() 98 | 99 | return t 100 | } 101 | 102 | func setupSearchTable() table.Model { 103 | return table.New( 104 | table.WithColumns(tableColumnsGithubRepository), 105 | table.WithRows([]table.Row{}), 106 | ) 107 | } 108 | 109 | func setupTextInput() textinput.Model { 110 | ti := textinput.New() 111 | ti.Blur() 112 | ti.CharLimit = 128 113 | ti.Placeholder = "Type to search repository" 114 | ti.ShowSuggestions = false 115 | return ti 116 | } 117 | 118 | // ----------------------------------------------------------------------------- 119 | // Bubbletea Model Implementation 120 | // ----------------------------------------------------------------------------- 121 | 122 | func (m *ModelGithubRepository) Init() tea.Cmd { 123 | m.setupBrowserOption() 124 | go m.syncRepositories(m.syncRepositoriesContext) 125 | 126 | return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { 127 | return initSyncMsg{} 128 | }) 129 | } 130 | 131 | func (m *ModelGithubRepository) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 132 | var cmds []tea.Cmd 133 | var cmd tea.Cmd 134 | 135 | inputMsg := msg 136 | switch msg := msg.(type) { 137 | case initSyncMsg: 138 | m.modelTabOptions.SetStatus(StatusIdle) 139 | m.tableGithubRepository.SetCursor(0) 140 | return m, nil 141 | case tea.KeyMsg: 142 | // Handle number keys for tab options 143 | if m.isNumber(msg.String()) { 144 | inputMsg = tea.KeyMsg{} 145 | } 146 | 147 | // Handle refresh key 148 | if key.Matches(msg, m.Keys.Refresh) { 149 | m.tableReady = false 150 | m.cancelSyncRepositories() 151 | m.syncRepositoriesContext, m.cancelSyncRepositories = context.WithCancel(context.Background()) 152 | go m.syncRepositories(m.syncRepositoriesContext) 153 | return m, nil 154 | } 155 | 156 | // Handle character input for search 157 | if m.isCharAndSymbol(msg.Runes) { 158 | m.resetTableCursors() 159 | } 160 | } 161 | 162 | // Update text input and search functionality 163 | if cmd := m.updateTextInput(inputMsg); cmd != nil { 164 | cmds = append(cmds, cmd) 165 | } 166 | 167 | // Update main table and handle row selection 168 | if cmd := m.updateTable(msg); cmd != nil { 169 | cmds = append(cmds, cmd) 170 | } 171 | 172 | m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) 173 | cmds = append(cmds, cmd) 174 | 175 | return m, tea.Batch(cmds...) 176 | } 177 | 178 | func (m *ModelGithubRepository) updateTextInput(msg tea.Msg) tea.Cmd { 179 | var cmd tea.Cmd 180 | m.textInput, cmd = m.textInput.Update(msg) 181 | m.updateTableRowsBySearchBar() 182 | return cmd 183 | } 184 | 185 | func (m *ModelGithubRepository) updateTable(msg tea.Msg) tea.Cmd { 186 | var cmds []tea.Cmd 187 | var cmd tea.Cmd 188 | 189 | // Update main table 190 | m.tableGithubRepository, cmd = m.tableGithubRepository.Update(msg) 191 | cmds = append(cmds, cmd) 192 | 193 | // Update search table 194 | m.searchTableGithubRepository, cmd = m.searchTableGithubRepository.Update(msg) 195 | cmds = append(cmds, cmd) 196 | 197 | // Handle table selection 198 | m.handleTableInputs(m.syncRepositoriesContext) 199 | 200 | m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) 201 | cmds = append(cmds, cmd) 202 | 203 | return tea.Batch(cmds...) 204 | } 205 | 206 | func (m *ModelGithubRepository) View() string { 207 | return lipgloss.JoinVertical(lipgloss.Top, 208 | m.renderTable(), 209 | m.renderSearchBar(), 210 | m.modelTabOptions.View(), 211 | m.status.View(), 212 | m.renderHelp(), 213 | ) 214 | } 215 | 216 | // ----------------------------------------------------------------------------- 217 | // UI Rendering 218 | // ----------------------------------------------------------------------------- 219 | 220 | func (m *ModelGithubRepository) renderTable() string { 221 | baseStyle := lipgloss.NewStyle(). 222 | BorderStyle(lipgloss.NormalBorder()). 223 | BorderForeground(lipgloss.Color("#3b698f")). 224 | MarginLeft(1) 225 | 226 | // Update table dimensions 227 | m.updateTableDimensions() 228 | 229 | return baseStyle.Render(m.tableGithubRepository.View()) 230 | } 231 | 232 | func (m *ModelGithubRepository) renderSearchBar() string { 233 | style := lipgloss.NewStyle(). 234 | Border(lipgloss.NormalBorder()). 235 | BorderForeground(lipgloss.Color("#3b698f")). 236 | Padding(0, 1). 237 | Width(m.skeleton.GetTerminalWidth() - 6). 238 | MarginLeft(1) 239 | 240 | if len(m.textInput.Value()) > 0 { 241 | style = style.BorderForeground(lipgloss.Color("39")) 242 | } 243 | 244 | return style.Render(m.textInput.View()) 245 | } 246 | 247 | func (m *ModelGithubRepository) renderHelp() string { 248 | helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) 249 | return helpStyle.Render(m.ViewHelp()) 250 | } 251 | 252 | // ----------------------------------------------------------------------------- 253 | // Data Synchronization 254 | // ----------------------------------------------------------------------------- 255 | 256 | func (m *ModelGithubRepository) syncRepositories(ctx context.Context) { 257 | defer m.skeleton.TriggerUpdate() 258 | 259 | m.status.Reset() 260 | m.status.SetProgressMessage("Fetching repositories...") 261 | m.clearTables() 262 | 263 | // Add timeout to prevent hanging 264 | ctx, cancel := context.WithTimeout(ctx, 30*time.Second) 265 | defer cancel() 266 | 267 | repos, err := m.fetchRepositories(ctx) 268 | if err != nil { 269 | m.handleFetchError(err) 270 | return 271 | } 272 | 273 | m.updateRepositoryData(repos) 274 | } 275 | 276 | func (m *ModelGithubRepository) fetchRepositories(ctx context.Context) (*gu.ListRepositoriesOutput, error) { 277 | return m.github.ListRepositories(ctx, gu.ListRepositoriesInput{ 278 | Limit: 100, 279 | Page: 5, 280 | Sort: domain.SortByUpdated, 281 | }) 282 | } 283 | 284 | // ----------------------------------------------------------------------------- 285 | // Table Management 286 | // ----------------------------------------------------------------------------- 287 | 288 | func (m *ModelGithubRepository) clearTables() { 289 | m.tableGithubRepository.SetRows([]table.Row{}) 290 | m.searchTableGithubRepository.SetRows([]table.Row{}) 291 | } 292 | 293 | func (m *ModelGithubRepository) updateTableDimensions() { 294 | const minTableWidth = 60 // Minimum width to maintain readability 295 | const tablePadding = 14 // Account for borders and margins 296 | 297 | var tableWidth int 298 | for _, t := range tableColumnsGithubRepository { 299 | tableWidth += t.Width 300 | } 301 | 302 | termWidth := m.skeleton.GetTerminalWidth() 303 | if termWidth <= minTableWidth { 304 | return // Prevent table from becoming too narrow 305 | } 306 | 307 | newTableColumns := make([]table.Column, len(tableColumnsGithubRepository)) 308 | copy(newTableColumns, tableColumnsGithubRepository) 309 | 310 | widthDiff := termWidth - tableWidth - tablePadding 311 | if widthDiff > 0 { 312 | // Add extra width to repository name column 313 | newTableColumns[0].Width += widthDiff 314 | m.tableGithubRepository.SetColumns(newTableColumns) 315 | 316 | // Adjust height while maintaining some padding 317 | maxHeight := m.skeleton.GetTerminalHeight() - 20 318 | if maxHeight > 0 { 319 | m.tableGithubRepository.SetHeight(maxHeight) 320 | } 321 | } 322 | } 323 | 324 | func (m *ModelGithubRepository) resetTableCursors() { 325 | m.tableGithubRepository.GotoTop() 326 | m.tableGithubRepository.SetCursor(0) 327 | m.searchTableGithubRepository.GotoTop() 328 | m.searchTableGithubRepository.SetCursor(0) 329 | } 330 | 331 | // ----------------------------------------------------------------------------- 332 | // Repository Data Management 333 | // ----------------------------------------------------------------------------- 334 | 335 | func (m *ModelGithubRepository) updateRepositoryData(repos *gu.ListRepositoriesOutput) { 336 | if len(repos.Repositories) == 0 { 337 | m.modelTabOptions.SetStatus(StatusNone) 338 | m.status.SetDefaultMessage("No repositories found") 339 | m.textInput.Blur() 340 | return 341 | } 342 | 343 | m.skeleton.UpdateWidgetValue("repositories", fmt.Sprintf("Repository Count: %d", len(repos.Repositories))) 344 | m.updateTableRows(repos.Repositories) 345 | m.finalizeTableUpdate() 346 | } 347 | 348 | func (m *ModelGithubRepository) updateTableRows(repositories []gu.GithubRepository) { 349 | rows := make([]table.Row, 0, len(repositories)) 350 | for _, repo := range repositories { 351 | rows = append(rows, table.Row{ 352 | repo.Name, 353 | repo.DefaultBranch, 354 | strconv.Itoa(repo.Stars), 355 | strconv.Itoa(len(repo.Workflows)), 356 | }) 357 | } 358 | 359 | m.tableGithubRepository.SetRows(rows) 360 | m.searchTableGithubRepository.SetRows(rows) 361 | } 362 | 363 | func (m *ModelGithubRepository) finalizeTableUpdate() { 364 | m.tableGithubRepository.SetCursor(0) 365 | m.searchTableGithubRepository.SetCursor(0) 366 | m.tableReady = true 367 | m.textInput.Focus() 368 | m.status.SetSuccessMessage("Repositories fetched") 369 | 370 | m.skeleton.TriggerUpdateWithMsg(initSyncMsg{}) 371 | } 372 | 373 | // ----------------------------------------------------------------------------- 374 | // Search Functionality 375 | // ----------------------------------------------------------------------------- 376 | 377 | func (m *ModelGithubRepository) updateTableRowsBySearchBar() { 378 | searchValue := strings.ToLower(m.textInput.Value()) 379 | if searchValue == "" { 380 | // If search is empty, restore original rows 381 | m.tableGithubRepository.SetRows(m.searchTableGithubRepository.Rows()) 382 | return 383 | } 384 | 385 | rows := m.searchTableGithubRepository.Rows() 386 | filteredRows := make([]table.Row, 0, len(rows)) 387 | 388 | for _, row := range rows { 389 | if strings.Contains(strings.ToLower(row[0]), searchValue) { 390 | filteredRows = append(filteredRows, row) 391 | } 392 | } 393 | 394 | m.tableGithubRepository.SetRows(filteredRows) 395 | if len(filteredRows) == 0 { 396 | m.clearSelectedRepository() 397 | } 398 | } 399 | 400 | func (m *ModelGithubRepository) clearSelectedRepository() { 401 | m.selectedRepository.RepositoryName = "" 402 | m.selectedRepository.BranchName = "" 403 | m.selectedRepository.WorkflowName = "" 404 | } 405 | 406 | // ----------------------------------------------------------------------------- 407 | // Input Validation & Handling 408 | // ----------------------------------------------------------------------------- 409 | 410 | func (m *ModelGithubRepository) isNumber(s string) bool { 411 | _, err := strconv.Atoi(s) 412 | return err == nil 413 | } 414 | 415 | func (m *ModelGithubRepository) isCharAndSymbol(r []rune) bool { 416 | const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_./" 417 | for _, c := range r { 418 | if strings.ContainsRune(chars, c) { 419 | return true 420 | } 421 | } 422 | return false 423 | } 424 | 425 | // ----------------------------------------------------------------------------- 426 | // Browser Integration 427 | // ----------------------------------------------------------------------------- 428 | 429 | func (m *ModelGithubRepository) setupBrowserOption() { 430 | openInBrowser := func() { 431 | m.status.SetProgressMessage("Opening in browser...") 432 | 433 | url := fmt.Sprintf("https://github.com/%s", m.selectedRepository.RepositoryName) 434 | if err := browser.OpenInBrowser(url); err != nil { 435 | m.status.SetError(err) 436 | m.status.SetErrorMessage(fmt.Sprintf("Cannot open in browser: %v", err)) 437 | return 438 | } 439 | 440 | m.status.SetSuccessMessage("Opened in browser") 441 | } 442 | 443 | m.modelTabOptions.AddOption("Open in browser", openInBrowser) 444 | } 445 | 446 | // ----------------------------------------------------------------------------- 447 | // Error Handling 448 | // ----------------------------------------------------------------------------- 449 | 450 | func (m *ModelGithubRepository) handleFetchError(err error) { 451 | if errors.Is(err, context.Canceled) { 452 | m.status.SetDefaultMessage("Repository fetch cancelled") 453 | return 454 | } 455 | if errors.Is(err, context.DeadlineExceeded) { 456 | m.status.SetErrorMessage("Repository fetch timed out") 457 | return 458 | } 459 | 460 | m.status.SetError(err) 461 | m.status.SetErrorMessage(fmt.Sprintf("Failed to list repositories: %v", err)) 462 | } 463 | 464 | // ----------------------------------------------------------------------------- 465 | // Table Selection Handling 466 | // ----------------------------------------------------------------------------- 467 | 468 | func (m *ModelGithubRepository) handleTableInputs(_ context.Context) { 469 | if !m.tableReady { 470 | return 471 | } 472 | 473 | selectedRow := m.tableGithubRepository.SelectedRow() 474 | if len(selectedRow) > 0 && selectedRow[0] != "" { 475 | m.updateSelectedRepository(selectedRow) 476 | } 477 | } 478 | 479 | func (m *ModelGithubRepository) updateSelectedRepository(row []string) { 480 | m.selectedRepository.RepositoryName = row[0] 481 | m.selectedRepository.BranchName = row[1] 482 | 483 | if workflowCount := row[3]; workflowCount != "" { 484 | m.handleWorkflowTabLocking(workflowCount) 485 | } 486 | } 487 | 488 | func (m *ModelGithubRepository) handleWorkflowTabLocking(workflowCount string) { 489 | count, _ := strconv.Atoi(workflowCount) 490 | if count == 0 { 491 | m.skeleton.LockTab("workflow") 492 | m.skeleton.LockTab("trigger") 493 | } else { 494 | m.skeleton.UnlockTab("workflow") 495 | m.skeleton.UnlockTab("trigger") 496 | } 497 | } 498 | 499 | // initSyncMsg is a message type used to trigger a UI update after the initial sync 500 | type initSyncMsg struct{} 501 | -------------------------------------------------------------------------------- /internal/terminal/handler/ghtrigger.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "slices" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/bubbles/help" 12 | "github.com/charmbracelet/bubbles/table" 13 | "github.com/charmbracelet/bubbles/textinput" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | gu "github.com/termkit/gama/internal/github/usecase" 17 | "github.com/termkit/gama/pkg/workflow" 18 | "github.com/termkit/skeleton" 19 | ) 20 | 21 | type ModelGithubTrigger struct { 22 | skeleton *skeleton.Skeleton 23 | 24 | // current handler's properties 25 | syncWorkflowContext context.Context 26 | cancelSyncWorkflow context.CancelFunc 27 | workflowContent *workflow.Pretty 28 | tableReady bool 29 | isTriggerable bool 30 | optionInit bool 31 | optionCursor int 32 | optionValues []string 33 | currentBranch string 34 | currentOption string 35 | selectedWorkflow string 36 | selectedRepositoryName string 37 | triggerFocused bool 38 | 39 | // shared properties 40 | selectedRepository *SelectedRepository 41 | 42 | // use cases 43 | github gu.UseCase 44 | 45 | // keymap 46 | Keys githubTriggerKeyMap 47 | 48 | // models 49 | help help.Model 50 | status *ModelStatus 51 | textInput textinput.Model 52 | tableTrigger table.Model 53 | } 54 | 55 | func SetupModelGithubTrigger(sk *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubTrigger { 56 | var tableRowsTrigger []table.Row 57 | 58 | tableTrigger := table.New( 59 | table.WithColumns(tableColumnsTrigger), 60 | table.WithRows(tableRowsTrigger), 61 | table.WithFocused(true), 62 | table.WithHeight(7), 63 | ) 64 | 65 | // Apply styles 66 | tableTrigger.SetStyles(defaultTableStyles()) 67 | 68 | // Apply keymap 69 | tableTrigger.KeyMap = defaultTableKeyMap() 70 | 71 | ti := textinput.New() 72 | ti.Blur() 73 | ti.CharLimit = 160 74 | 75 | modelStatus := SetupModelStatus(sk) 76 | return &ModelGithubTrigger{ 77 | skeleton: sk, 78 | help: help.New(), 79 | Keys: githubTriggerKeys, 80 | github: githubUseCase, 81 | selectedRepository: NewSelectedRepository(), 82 | status: modelStatus, 83 | tableTrigger: tableTrigger, 84 | textInput: ti, 85 | syncWorkflowContext: context.Background(), 86 | cancelSyncWorkflow: func() {}, 87 | } 88 | } 89 | 90 | func (m *ModelGithubTrigger) Init() tea.Cmd { 91 | return tea.Batch(textinput.Blink) 92 | } 93 | 94 | func (m *ModelGithubTrigger) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 95 | if m.selectedRepository.WorkflowName == "" { 96 | m.status.Reset() 97 | m.status.SetDefaultMessage("No workflow selected.") 98 | m.fillTableWithEmptyMessage() 99 | return m, nil 100 | } 101 | 102 | if m.selectedRepository.WorkflowName != "" && (m.selectedRepository.WorkflowName != m.selectedWorkflow || 103 | m.selectedRepository.RepositoryName != m.selectedRepositoryName || 104 | m.selectedRepository.BranchName != m.currentBranch) { 105 | 106 | m.tableReady = false 107 | m.isTriggerable = false 108 | m.triggerFocused = false 109 | 110 | m.cancelSyncWorkflow() // cancel previous sync workflow 111 | 112 | m.selectedWorkflow = m.selectedRepository.WorkflowName 113 | m.selectedRepositoryName = m.selectedRepository.RepositoryName 114 | m.currentBranch = m.selectedRepository.BranchName 115 | m.syncWorkflowContext, m.cancelSyncWorkflow = context.WithCancel(context.Background()) 116 | 117 | go m.syncWorkflowContent(m.syncWorkflowContext) 118 | } 119 | 120 | var cmds []tea.Cmd 121 | var cmd tea.Cmd 122 | 123 | switch shadowMsg := msg.(type) { 124 | case tea.KeyMsg: 125 | switch shadowMsg.String() { 126 | case "up": 127 | if len(m.tableTrigger.Rows()) > 0 && !m.triggerFocused { 128 | m.tableTrigger.MoveUp(1) 129 | m.switchBetweenInputAndTable() 130 | // delete msg key to prevent moving cursor 131 | msg = tea.KeyMsg{Type: tea.KeyNull} 132 | 133 | m.optionInit = false 134 | } 135 | case "down": 136 | if len(m.tableTrigger.Rows()) > 0 && !m.triggerFocused { 137 | m.tableTrigger.MoveDown(1) 138 | m.switchBetweenInputAndTable() 139 | // delete msg key to prevent moving cursor 140 | msg = tea.KeyMsg{Type: tea.KeyNull} 141 | 142 | m.optionInit = false 143 | } 144 | case "ctrl+r", "ctrl+R": 145 | go m.syncWorkflowContent(m.syncWorkflowContext) 146 | case "left": 147 | if !m.triggerFocused { 148 | m.optionCursor = max(m.optionCursor-1, 0) 149 | } 150 | case "right": 151 | if !m.triggerFocused { 152 | m.optionCursor = min(m.optionCursor+1, len(m.optionValues)-1) 153 | } 154 | case "tab": 155 | if m.isTriggerable { 156 | m.triggerFocused = !m.triggerFocused 157 | if m.triggerFocused { 158 | m.tableTrigger.Blur() 159 | m.textInput.Blur() 160 | m.showInformationIfAnyEmptyValue() 161 | } else { 162 | m.tableTrigger.Focus() 163 | m.textInput.Focus() 164 | } 165 | } 166 | case "enter", tea.KeyEnter.String(): 167 | if m.triggerFocused && m.isTriggerable { 168 | go m.triggerWorkflow() 169 | } 170 | } 171 | } 172 | 173 | m.tableTrigger, cmd = m.tableTrigger.Update(msg) 174 | cmds = append(cmds, cmd) 175 | 176 | m.textInput, cmd = m.textInput.Update(msg) 177 | cmds = append(cmds, cmd) 178 | 179 | m.inputController(m.syncWorkflowContext) 180 | 181 | return m, tea.Batch(cmds...) 182 | } 183 | 184 | func (m *ModelGithubTrigger) View() string { 185 | baseStyle := lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).MarginLeft(1) 186 | helpWindowStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) 187 | 188 | if m.triggerFocused { 189 | baseStyle = baseStyle.BorderForeground(lipgloss.Color("240")) 190 | } else { 191 | baseStyle = baseStyle.BorderForeground(lipgloss.Color("#3b698f")) 192 | } 193 | 194 | var tableWidth int 195 | for _, t := range tableColumnsTrigger { 196 | tableWidth += t.Width 197 | } 198 | 199 | newTableColumns := tableColumnsTrigger 200 | widthDiff := m.skeleton.GetTerminalWidth() - tableWidth 201 | if widthDiff > 0 { 202 | keyWidth := &newTableColumns[2].Width 203 | valueWidth := &newTableColumns[4].Width 204 | 205 | *valueWidth += widthDiff - 16 206 | if *valueWidth%2 == 0 { 207 | *keyWidth = *valueWidth / 2 208 | } 209 | m.tableTrigger.SetColumns(newTableColumns) 210 | m.tableTrigger.SetHeight(m.skeleton.GetTerminalHeight() - 17) 211 | } 212 | 213 | var selectedRow = m.tableTrigger.SelectedRow() 214 | var selector = m.emptySelector() 215 | if len(m.tableTrigger.Rows()) > 0 { 216 | if selectedRow[1] == "input" { 217 | selector = m.inputSelector() 218 | } else { 219 | selector = m.optionSelector() 220 | } 221 | } 222 | 223 | return lipgloss.JoinVertical(lipgloss.Top, 224 | baseStyle.Render(m.tableTrigger.View()), lipgloss.JoinHorizontal(lipgloss.Top, selector, m.triggerButton()), 225 | m.status.View(), helpWindowStyle.Render(m.ViewHelp())) 226 | } 227 | 228 | func (m *ModelGithubTrigger) switchBetweenInputAndTable() { 229 | var selectedRow = m.tableTrigger.SelectedRow() 230 | 231 | if selectedRow[1] == "input" || selectedRow[1] == "bool" { 232 | m.textInput.Focus() 233 | m.tableTrigger.Blur() 234 | } else { 235 | m.textInput.Blur() 236 | m.tableTrigger.Focus() 237 | } 238 | m.textInput.SetValue(m.tableTrigger.SelectedRow()[4]) 239 | m.textInput.SetCursor(len(m.textInput.Value())) 240 | } 241 | 242 | func (m *ModelGithubTrigger) inputController(_ context.Context) { 243 | if m.workflowContent == nil { 244 | return 245 | } 246 | 247 | if len(m.tableTrigger.Rows()) > 0 { 248 | var selectedRow = m.tableTrigger.SelectedRow() 249 | if len(selectedRow) == 0 { 250 | return 251 | } 252 | 253 | switch selectedRow[1] { 254 | case "choice": 255 | var optionValues []string 256 | for _, choice := range m.workflowContent.Choices { 257 | if fmt.Sprintf("%d", choice.ID) == selectedRow[0] { 258 | optionValues = append(optionValues, choice.Values...) 259 | } 260 | } 261 | m.optionValues = optionValues 262 | if !m.optionInit { 263 | for i, option := range m.optionValues { 264 | if option == selectedRow[4] { 265 | m.optionCursor = i 266 | } 267 | } 268 | } 269 | m.optionInit = true 270 | case "bool": 271 | var optionValues []string 272 | for _, choice := range m.workflowContent.Boolean { 273 | if fmt.Sprintf("%d", choice.ID) == selectedRow[0] { 274 | optionValues = append(optionValues, choice.Values...) 275 | } 276 | } 277 | m.optionValues = optionValues 278 | if !m.optionInit { 279 | for i, option := range m.optionValues { 280 | if option == selectedRow[4] { 281 | m.optionCursor = i 282 | } 283 | } 284 | } 285 | m.optionInit = true 286 | default: 287 | m.optionValues = nil 288 | m.optionCursor = 0 289 | 290 | if !m.triggerFocused { 291 | m.textInput.Focus() 292 | } 293 | } 294 | } 295 | 296 | for i, choice := range m.workflowContent.Choices { 297 | var selectedRow = m.tableTrigger.SelectedRow() 298 | var rows = m.tableTrigger.Rows() 299 | 300 | if len(selectedRow) == 0 || len(rows) == 0 { 301 | return 302 | } 303 | if fmt.Sprintf("%d", choice.ID) == selectedRow[0] { 304 | m.workflowContent.Choices[i].SetValue(m.optionValues[m.optionCursor]) 305 | 306 | for i, row := range rows { 307 | if row[0] == selectedRow[0] { 308 | rows[i][4] = m.optionValues[m.optionCursor] 309 | } 310 | } 311 | 312 | m.tableTrigger.SetRows(rows) 313 | } 314 | } 315 | 316 | if m.workflowContent.Boolean != nil { 317 | for i, boolean := range m.workflowContent.Boolean { 318 | var selectedRow = m.tableTrigger.SelectedRow() 319 | var rows = m.tableTrigger.Rows() 320 | if len(selectedRow) == 0 || len(rows) == 0 { 321 | return 322 | } 323 | if fmt.Sprintf("%d", boolean.ID) == selectedRow[0] { 324 | m.workflowContent.Boolean[i].SetValue(m.optionValues[m.optionCursor]) 325 | 326 | for i, row := range rows { 327 | if row[0] == selectedRow[0] { 328 | rows[i][4] = m.optionValues[m.optionCursor] 329 | } 330 | } 331 | 332 | m.tableTrigger.SetRows(rows) 333 | } 334 | } 335 | } 336 | 337 | if m.textInput.Focused() { 338 | if strings.HasPrefix(m.textInput.Value(), " ") { 339 | m.textInput.SetValue("") 340 | } 341 | 342 | var selectedRow = m.tableTrigger.SelectedRow() 343 | var rows = m.tableTrigger.Rows() 344 | if len(selectedRow) == 0 || len(rows) == 0 { 345 | return 346 | } 347 | 348 | for i, input := range m.workflowContent.Inputs { 349 | if fmt.Sprintf("%d", input.ID) == selectedRow[0] { 350 | m.textInput.Placeholder = input.Default 351 | m.workflowContent.Inputs[i].SetValue(m.textInput.Value()) 352 | 353 | for i, row := range rows { 354 | if row[0] == selectedRow[0] { 355 | rows[i][4] = m.textInput.Value() 356 | } 357 | } 358 | 359 | m.tableTrigger.SetRows(rows) 360 | } 361 | } 362 | 363 | for i, keyVal := range m.workflowContent.KeyVals { 364 | if fmt.Sprintf("%d", keyVal.ID) == selectedRow[0] { 365 | m.textInput.Placeholder = keyVal.Default 366 | m.workflowContent.KeyVals[i].SetValue(m.textInput.Value()) 367 | 368 | for i, row := range rows { 369 | if row[0] == selectedRow[0] { 370 | rows[i][4] = m.textInput.Value() 371 | } 372 | } 373 | 374 | m.tableTrigger.SetRows(rows) 375 | } 376 | } 377 | } 378 | } 379 | 380 | func (m *ModelGithubTrigger) syncWorkflowContent(ctx context.Context) { 381 | defer m.skeleton.TriggerUpdate() 382 | 383 | m.status.Reset() 384 | m.status.SetProgressMessage( 385 | fmt.Sprintf("[%s@%s] Fetching workflow contents...", 386 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) 387 | 388 | // reset table rows 389 | m.tableTrigger.SetRows([]table.Row{}) 390 | 391 | workflowContent, err := m.github.InspectWorkflow(ctx, gu.InspectWorkflowInput{ 392 | Repository: m.selectedRepository.RepositoryName, 393 | Branch: m.selectedRepository.BranchName, 394 | WorkflowFile: m.selectedWorkflow, 395 | }) 396 | if errors.Is(err, context.Canceled) { 397 | return 398 | } else if err != nil { 399 | m.status.SetError(err) 400 | m.status.SetErrorMessage("Workflow contents cannot be fetched") 401 | return 402 | } 403 | 404 | if workflowContent.Workflow == nil { 405 | m.status.SetError(errors.New("workflow contents cannot be empty")) 406 | m.status.SetErrorMessage("You have no workflow contents") 407 | return 408 | } 409 | 410 | m.workflowContent = workflowContent.Workflow 411 | 412 | var tableRowsTrigger []table.Row 413 | for _, keyVal := range m.workflowContent.KeyVals { 414 | tableRowsTrigger = append(tableRowsTrigger, table.Row{ 415 | fmt.Sprintf("%d", keyVal.ID), 416 | "input", // json type 417 | keyVal.Key, 418 | keyVal.Default, 419 | keyVal.Value, 420 | }) 421 | } 422 | 423 | for _, choice := range m.workflowContent.Choices { 424 | tableRowsTrigger = append(tableRowsTrigger, table.Row{ 425 | fmt.Sprintf("%d", choice.ID), 426 | "choice", 427 | choice.Key, 428 | choice.Default, 429 | choice.Value, 430 | }) 431 | } 432 | 433 | for _, input := range m.workflowContent.Inputs { 434 | tableRowsTrigger = append(tableRowsTrigger, table.Row{ 435 | fmt.Sprintf("%d", input.ID), 436 | "input", 437 | input.Key, 438 | input.Default, 439 | input.Value, 440 | }) 441 | } 442 | 443 | for _, boolean := range m.workflowContent.Boolean { 444 | tableRowsTrigger = append(tableRowsTrigger, table.Row{ 445 | fmt.Sprintf("%d", boolean.ID), 446 | "bool", 447 | boolean.Key, 448 | boolean.Default, 449 | boolean.Value, 450 | }) 451 | } 452 | 453 | m.tableTrigger.SetRows(tableRowsTrigger) 454 | m.sortTableItemsByName() 455 | m.tableTrigger.SetCursor(0) 456 | m.optionCursor = 0 457 | m.optionValues = nil 458 | m.triggerFocused = false 459 | m.tableTrigger.Focus() 460 | 461 | // reset input value 462 | m.textInput.SetCursor(0) 463 | m.textInput.SetValue("") 464 | m.textInput.Placeholder = "" 465 | 466 | m.tableReady = true 467 | m.isTriggerable = true 468 | 469 | if len(workflowContent.Workflow.KeyVals) == 0 && 470 | len(workflowContent.Workflow.Choices) == 0 && 471 | len(workflowContent.Workflow.Inputs) == 0 { 472 | m.fillTableWithEmptyMessage() 473 | m.status.SetDefaultMessage(fmt.Sprintf("[%s@%s] Workflow doesn't contain options but still triggerable", 474 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) 475 | } else { 476 | m.status.SetSuccessMessage(fmt.Sprintf("[%s@%s] Workflow contents fetched.", 477 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) 478 | } 479 | } 480 | 481 | func (m *ModelGithubTrigger) fillTableWithEmptyMessage() { 482 | var rows []table.Row 483 | for i := 0; i < 100; i++ { 484 | idx := fmt.Sprintf("%d", i) 485 | rows = append(rows, table.Row{ 486 | idx, "EMPTY", "EMPTY", "EMPTY", "No workflow input found", 487 | }) 488 | } 489 | 490 | m.tableTrigger.SetRows(rows) 491 | m.tableTrigger.SetCursor(0) 492 | } 493 | 494 | func (m *ModelGithubTrigger) showInformationIfAnyEmptyValue() { 495 | for _, row := range m.tableTrigger.Rows() { 496 | if row[4] == "" { 497 | m.status.SetDefaultMessage("Info: You have empty values. These values uses their default values.") 498 | return 499 | } 500 | } 501 | } 502 | 503 | func (m *ModelGithubTrigger) triggerButton() string { 504 | button := lipgloss.NewStyle(). 505 | Border(lipgloss.NormalBorder()). 506 | BorderForeground(lipgloss.Color("255")). 507 | Padding(0, 1). 508 | Align(lipgloss.Center) 509 | 510 | if m.triggerFocused { 511 | button = button.BorderForeground(lipgloss.Color("#399adb")). 512 | Foreground(lipgloss.Color("#399adb")). 513 | BorderStyle(lipgloss.DoubleBorder()) 514 | } 515 | 516 | return button.Render("Trigger") 517 | } 518 | 519 | func (m *ModelGithubTrigger) fillEmptyValuesWithDefault() { 520 | if m.workflowContent == nil { 521 | m.status.SetError(errors.New("workflow contents cannot be empty")) 522 | m.status.SetErrorMessage("You have no workflow contents") 523 | return 524 | } 525 | 526 | rows := m.tableTrigger.Rows() 527 | for i, row := range rows { 528 | if row[4] == "" { 529 | rows[i][4] = rows[i][3] 530 | } 531 | } 532 | m.tableTrigger.SetRows(rows) 533 | 534 | for i, choice := range m.workflowContent.Choices { 535 | if choice.Value == "" { 536 | m.workflowContent.Choices[i].SetValue(choice.Default) 537 | } 538 | 539 | } 540 | 541 | for i, input := range m.workflowContent.Inputs { 542 | if input.Value == "" { 543 | m.workflowContent.Inputs[i].SetValue(input.Default) 544 | } 545 | } 546 | 547 | for i, keyVal := range m.workflowContent.KeyVals { 548 | if keyVal.Value == "" { 549 | m.workflowContent.KeyVals[i].SetValue(keyVal.Default) 550 | } 551 | } 552 | 553 | for i, boolean := range m.workflowContent.Boolean { 554 | if boolean.Value == "" { 555 | m.workflowContent.Boolean[i].SetValue(boolean.Default) 556 | } 557 | } 558 | } 559 | 560 | func (m *ModelGithubTrigger) triggerWorkflow() { 561 | if m.triggerFocused { 562 | m.fillEmptyValuesWithDefault() 563 | } 564 | 565 | m.status.SetProgressMessage(fmt.Sprintf("[%s@%s]:[%s] Triggering workflow...", 566 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName, m.selectedWorkflow)) 567 | 568 | if m.workflowContent == nil { 569 | m.status.SetErrorMessage("Workflow contents cannot be empty") 570 | return 571 | } 572 | 573 | content, err := m.workflowContent.ToJson() 574 | if err != nil { 575 | m.status.SetError(err) 576 | m.status.SetErrorMessage("Workflow contents cannot be converted to JSON") 577 | return 578 | } 579 | 580 | err = m.github.TriggerWorkflow(context.Background(), gu.TriggerWorkflowInput{ 581 | Repository: m.selectedRepository.RepositoryName, 582 | Branch: m.selectedRepository.BranchName, 583 | WorkflowFile: m.selectedWorkflow, 584 | Content: content, 585 | }) 586 | if err != nil { 587 | m.status.SetError(err) 588 | m.status.SetErrorMessage("Workflow cannot be triggered") 589 | return 590 | } 591 | 592 | m.status.SetSuccessMessage(fmt.Sprintf("[%s@%s]:[%s] Workflow triggered.", 593 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName, m.selectedWorkflow)) 594 | 595 | m.skeleton.TriggerUpdate() 596 | m.status.SetProgressMessage("Switching to workflow history tab...") 597 | time.Sleep(2000 * time.Millisecond) 598 | 599 | // move these operations under new function named "resetTabSettings" 600 | m.workflowContent = nil // reset workflow content 601 | m.selectedWorkflow = "" // reset selected workflow 602 | m.currentOption = "" // reset current option 603 | m.optionValues = nil // reset option values 604 | m.selectedRepositoryName = "" // reset selected repository name 605 | 606 | m.skeleton.TriggerUpdateWithMsg(workflowHistoryUpdateMsg{time.Second * 3}) // update workflow history 607 | m.skeleton.SetActivePage("history") // switch tab to workflow history 608 | } 609 | 610 | func (m *ModelGithubTrigger) emptySelector() string { 611 | // Define window style 612 | windowStyle := lipgloss.NewStyle(). 613 | Border(lipgloss.NormalBorder()). 614 | BorderForeground(lipgloss.Color("#3b698f")). 615 | Padding(0, 1). 616 | Width(m.skeleton.GetTerminalWidth() - 17).MarginLeft(1) 617 | 618 | return windowStyle.Render("") 619 | } 620 | 621 | func (m *ModelGithubTrigger) inputSelector() string { 622 | // Define window style 623 | windowStyle := lipgloss.NewStyle(). 624 | Border(lipgloss.NormalBorder()). 625 | BorderForeground(lipgloss.Color("#3b698f")). 626 | Padding(0, 1). 627 | Width(m.skeleton.GetTerminalWidth() - 17).MarginLeft(1) 628 | 629 | return windowStyle.Render(m.textInput.View()) 630 | } 631 | 632 | // optionSelector renders the options list 633 | // TODO: Make this dynamic limited&sized. 634 | func (m *ModelGithubTrigger) optionSelector() string { 635 | // Define window style 636 | windowStyle := lipgloss.NewStyle(). 637 | Border(lipgloss.NormalBorder()). 638 | BorderForeground(lipgloss.Color("#3b698f")). 639 | Padding(0, 1). 640 | Width(m.skeleton.GetTerminalWidth() - 17).MarginLeft(1) 641 | 642 | // Define styles for selected and unselected options 643 | selectedOptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("120")).Padding(0, 1) 644 | unselectedOptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("140")).Padding(0, 1) 645 | 646 | var processedValues []string 647 | for i, option := range m.optionValues { 648 | if i == m.optionCursor { 649 | processedValues = append(processedValues, selectedOptionStyle.Render(option)) 650 | } else { 651 | processedValues = append(processedValues, unselectedOptionStyle.Render(option)) 652 | } 653 | } 654 | 655 | return windowStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, processedValues...)) 656 | } 657 | 658 | func (m *ModelGithubTrigger) sortTableItemsByName() { 659 | rows := m.tableTrigger.Rows() 660 | slices.SortFunc(rows, func(a, b table.Row) int { 661 | return strings.Compare(a[2], b[2]) 662 | }) 663 | m.tableTrigger.SetRows(rows) 664 | } 665 | -------------------------------------------------------------------------------- /internal/terminal/handler/ghworkflow.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sort" 8 | 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/table" 12 | "github.com/charmbracelet/bubbles/textinput" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | gu "github.com/termkit/gama/internal/github/usecase" 16 | "github.com/termkit/skeleton" 17 | ) 18 | 19 | // ----------------------------------------------------------------------------- 20 | // Model Definition 21 | // ----------------------------------------------------------------------------- 22 | 23 | type ModelGithubWorkflow struct { 24 | // Core dependencies 25 | skeleton *skeleton.Skeleton 26 | github gu.UseCase 27 | 28 | // UI Components 29 | help help.Model 30 | keys githubWorkflowKeyMap 31 | tableTriggerableWorkflow table.Model 32 | status *ModelStatus 33 | textInput textinput.Model 34 | 35 | // Table state 36 | tableReady bool 37 | 38 | // Context management 39 | syncTriggerableWorkflowsContext context.Context 40 | cancelSyncTriggerableWorkflows context.CancelFunc 41 | 42 | // Shared state 43 | selectedRepository *SelectedRepository 44 | 45 | // Indicates if there are any available workflows 46 | hasWorkflows bool 47 | lastSelectedRepository string // Track last repository for state persistence 48 | 49 | // State management 50 | state struct { 51 | Ready bool 52 | Repository struct { 53 | Current string 54 | Last string 55 | Branch string 56 | HasFlows bool 57 | } 58 | Syncing bool 59 | } 60 | } 61 | 62 | // ----------------------------------------------------------------------------- 63 | // Constructor & Initialization 64 | // ----------------------------------------------------------------------------- 65 | 66 | func SetupModelGithubWorkflow(s *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubWorkflow { 67 | modelStatus := SetupModelStatus(s) 68 | 69 | m := &ModelGithubWorkflow{ 70 | // Initialize core dependencies 71 | skeleton: s, 72 | github: githubUseCase, 73 | 74 | // Initialize UI components 75 | help: help.New(), 76 | keys: githubWorkflowKeys, 77 | status: modelStatus, 78 | textInput: setupBranchInput(), 79 | 80 | // Initialize state 81 | selectedRepository: NewSelectedRepository(), 82 | syncTriggerableWorkflowsContext: context.Background(), 83 | cancelSyncTriggerableWorkflows: func() {}, 84 | } 85 | 86 | // Setup table and blur initially 87 | m.tableTriggerableWorkflow = setupWorkflowTable() 88 | m.tableTriggerableWorkflow.Blur() 89 | m.textInput.Blur() 90 | 91 | return m 92 | } 93 | 94 | func setupBranchInput() textinput.Model { 95 | ti := textinput.New() 96 | ti.Focus() 97 | ti.CharLimit = 128 98 | ti.Placeholder = "Type to switch branch" 99 | ti.ShowSuggestions = true 100 | return ti 101 | } 102 | 103 | func setupWorkflowTable() table.Model { 104 | t := table.New( 105 | table.WithColumns(tableColumnsWorkflow), 106 | table.WithRows([]table.Row{}), 107 | table.WithFocused(true), 108 | table.WithHeight(7), 109 | ) 110 | 111 | // Apply styles 112 | s := table.DefaultStyles() 113 | s.Header = s.Header. 114 | BorderStyle(lipgloss.NormalBorder()). 115 | BorderForeground(lipgloss.Color("#3b698f")). 116 | BorderBottom(true). 117 | Bold(false) 118 | s.Selected = s.Selected. 119 | Foreground(lipgloss.Color("229")). 120 | Background(lipgloss.Color("57")). 121 | Bold(false) 122 | t.SetStyles(s) 123 | 124 | // Set keymap 125 | t.KeyMap = table.KeyMap{ 126 | LineUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), 127 | LineDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), 128 | PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), 129 | PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), 130 | GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "go to start")), 131 | GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "go to end")), 132 | } 133 | 134 | return t 135 | } 136 | 137 | // ----------------------------------------------------------------------------- 138 | // Bubbletea Model Implementation 139 | // ----------------------------------------------------------------------------- 140 | 141 | func (m *ModelGithubWorkflow) Init() tea.Cmd { 142 | // Check initial state 143 | if m.lastSelectedRepository == m.selectedRepository.RepositoryName && !m.hasWorkflows { 144 | m.skeleton.LockTab("trigger") 145 | // Blur components initially 146 | m.tableTriggerableWorkflow.Blur() 147 | m.textInput.Blur() 148 | } 149 | return nil 150 | } 151 | 152 | func (m *ModelGithubWorkflow) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 153 | m.handleRepositoryChange() 154 | 155 | var cmds []tea.Cmd 156 | var cmd tea.Cmd 157 | 158 | // Update text input and handle branch selection 159 | m.textInput, cmd = m.textInput.Update(msg) 160 | cmds = append(cmds, cmd) 161 | m.handleBranchSelection() 162 | 163 | // Update table and handle workflow selection 164 | m.tableTriggerableWorkflow, cmd = m.tableTriggerableWorkflow.Update(msg) 165 | cmds = append(cmds, cmd) 166 | m.handleTableInputs() 167 | 168 | return m, tea.Batch(cmds...) 169 | } 170 | 171 | func (m *ModelGithubWorkflow) View() string { 172 | return lipgloss.JoinVertical(lipgloss.Top, 173 | m.renderTable(), 174 | m.renderBranchInput(), 175 | m.status.View(), 176 | m.renderHelp(), 177 | ) 178 | } 179 | 180 | // ----------------------------------------------------------------------------- 181 | // Repository Change Handling 182 | // ----------------------------------------------------------------------------- 183 | 184 | func (m *ModelGithubWorkflow) handleRepositoryChange() { 185 | if m.state.Repository.Current != m.selectedRepository.RepositoryName { 186 | m.state.Ready = false 187 | m.state.Repository.Current = m.selectedRepository.RepositoryName 188 | m.state.Repository.Branch = m.selectedRepository.BranchName 189 | m.syncWorkflows() 190 | } else if !m.state.Repository.HasFlows { 191 | m.skeleton.LockTab("trigger") 192 | } 193 | } 194 | 195 | // ----------------------------------------------------------------------------- 196 | // Branch Selection & Management 197 | // ----------------------------------------------------------------------------- 198 | 199 | func (m *ModelGithubWorkflow) handleBranchSelection() { 200 | selectedBranch := m.textInput.Value() 201 | 202 | // Set branch 203 | if selectedBranch == "" { 204 | m.selectedRepository.BranchName = m.state.Repository.Branch 205 | } else if m.isBranchValid(selectedBranch) { 206 | m.selectedRepository.BranchName = selectedBranch 207 | } else { 208 | m.status.SetErrorMessage(fmt.Sprintf("Branch %s does not exist", selectedBranch)) 209 | m.skeleton.LockTabsToTheRight() 210 | return 211 | } 212 | 213 | // Update tab state 214 | m.updateTabState() 215 | } 216 | 217 | func (m *ModelGithubWorkflow) isBranchValid(branch string) bool { 218 | for _, suggestion := range m.textInput.AvailableSuggestions() { 219 | if suggestion == branch { 220 | return true 221 | } 222 | } 223 | return false 224 | } 225 | 226 | // ----------------------------------------------------------------------------- 227 | // Workflow Sync & Management 228 | // ----------------------------------------------------------------------------- 229 | 230 | func (m *ModelGithubWorkflow) syncWorkflows() { 231 | if m.state.Syncing { 232 | m.cancelSyncTriggerableWorkflows() 233 | } 234 | 235 | ctx, cancel := context.WithCancel(context.Background()) 236 | m.cancelSyncTriggerableWorkflows = cancel 237 | m.state.Syncing = true 238 | 239 | go func() { 240 | defer func() { 241 | m.state.Syncing = false 242 | m.skeleton.TriggerUpdate() 243 | }() 244 | 245 | m.syncBranches(ctx) 246 | m.syncTriggerableWorkflows(ctx) 247 | }() 248 | } 249 | 250 | func (m *ModelGithubWorkflow) syncTriggerableWorkflows(ctx context.Context) { 251 | defer m.skeleton.TriggerUpdate() 252 | 253 | m.initializeSyncState() 254 | workflows, err := m.fetchTriggerableWorkflows(ctx) 255 | if err != nil { 256 | return 257 | } 258 | 259 | m.processWorkflows(workflows) 260 | } 261 | 262 | func (m *ModelGithubWorkflow) initializeSyncState() { 263 | m.status.Reset() 264 | m.status.SetProgressMessage(fmt.Sprintf("[%s@%s] Fetching triggerable workflows...", 265 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) 266 | m.tableTriggerableWorkflow.SetRows([]table.Row{}) 267 | } 268 | 269 | func (m *ModelGithubWorkflow) fetchTriggerableWorkflows(ctx context.Context) (*gu.GetTriggerableWorkflowsOutput, error) { 270 | workflows, err := m.github.GetTriggerableWorkflows(ctx, gu.GetTriggerableWorkflowsInput{ 271 | Repository: m.selectedRepository.RepositoryName, 272 | Branch: m.selectedRepository.BranchName, 273 | }) 274 | 275 | if err != nil { 276 | if !errors.Is(err, context.Canceled) { 277 | m.status.SetError(err) 278 | m.status.SetErrorMessage("Triggerable workflows cannot be listed") 279 | } 280 | return nil, err 281 | } 282 | 283 | return workflows, nil 284 | } 285 | 286 | func (m *ModelGithubWorkflow) processWorkflows(workflows *gu.GetTriggerableWorkflowsOutput) { 287 | m.state.Repository.HasFlows = len(workflows.TriggerableWorkflows) > 0 288 | m.state.Repository.Current = m.selectedRepository.RepositoryName 289 | m.state.Ready = true 290 | 291 | if !m.state.Repository.HasFlows { 292 | m.handleEmptyWorkflows() 293 | return 294 | } 295 | 296 | m.updateWorkflowTable(workflows.TriggerableWorkflows) 297 | m.updateTabState() 298 | m.finalizeUpdate() 299 | 300 | // Focus components when workflows exist 301 | m.tableTriggerableWorkflow.Focus() 302 | m.textInput.Focus() 303 | } 304 | 305 | func (m *ModelGithubWorkflow) handleEmptyWorkflows() { 306 | m.selectedRepository.WorkflowName = "" 307 | m.skeleton.LockTab("trigger") 308 | 309 | // Blur components when no workflows 310 | m.tableTriggerableWorkflow.Blur() 311 | m.textInput.Blur() 312 | 313 | m.status.SetDefaultMessage(fmt.Sprintf("[%s@%s] No triggerable workflow found.", 314 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) 315 | 316 | m.fillTableWithEmptyMessage() 317 | } 318 | 319 | func (m *ModelGithubWorkflow) fillTableWithEmptyMessage() { 320 | var rows []table.Row 321 | for i := 0; i < 100; i++ { 322 | rows = append(rows, table.Row{ 323 | "EMPTY", 324 | "No triggerable workflow found", 325 | }) 326 | } 327 | 328 | m.tableTriggerableWorkflow.SetRows(rows) 329 | m.tableTriggerableWorkflow.SetCursor(0) 330 | } 331 | 332 | // ----------------------------------------------------------------------------- 333 | // Branch Sync & Management 334 | // ----------------------------------------------------------------------------- 335 | 336 | func (m *ModelGithubWorkflow) syncBranches(ctx context.Context) { 337 | defer m.skeleton.TriggerUpdate() 338 | 339 | m.status.Reset() 340 | m.status.SetProgressMessage(fmt.Sprintf("[%s] Fetching branches...", 341 | m.selectedRepository.RepositoryName)) 342 | 343 | branches, err := m.fetchBranches(ctx) 344 | if err != nil { 345 | return 346 | } 347 | 348 | m.processBranches(branches) 349 | } 350 | 351 | func (m *ModelGithubWorkflow) fetchBranches(ctx context.Context) (*gu.GetRepositoryBranchesOutput, error) { 352 | branches, err := m.github.GetRepositoryBranches(ctx, gu.GetRepositoryBranchesInput{ 353 | Repository: m.selectedRepository.RepositoryName, 354 | }) 355 | 356 | if err != nil { 357 | if !errors.Is(err, context.Canceled) { 358 | m.status.SetError(err) 359 | m.status.SetErrorMessage("Branches cannot be listed") 360 | } 361 | return nil, err 362 | } 363 | 364 | return branches, nil 365 | } 366 | 367 | func (m *ModelGithubWorkflow) processBranches(branches *gu.GetRepositoryBranchesOutput) { 368 | if branches == nil || len(branches.Branches) == 0 { 369 | m.handleEmptyBranches() 370 | return 371 | } 372 | 373 | branchNames := make([]string, len(branches.Branches)) 374 | for i, branch := range branches.Branches { 375 | branchNames[i] = branch.Name 376 | } 377 | 378 | m.textInput.SetSuggestions(branchNames) 379 | m.status.SetSuccessMessage(fmt.Sprintf("[%s] Branches fetched.", 380 | m.selectedRepository.RepositoryName)) 381 | } 382 | 383 | func (m *ModelGithubWorkflow) handleEmptyBranches() { 384 | m.selectedRepository.BranchName = "" 385 | m.status.SetDefaultMessage(fmt.Sprintf("[%s] No branches found.", 386 | m.selectedRepository.RepositoryName)) 387 | } 388 | 389 | // ----------------------------------------------------------------------------- 390 | // Table Management 391 | // ----------------------------------------------------------------------------- 392 | 393 | func (m *ModelGithubWorkflow) updateWorkflowTable(workflows []gu.TriggerableWorkflow) { 394 | rows := make([]table.Row, 0, len(workflows)) 395 | for _, workflow := range workflows { 396 | rows = append(rows, table.Row{ 397 | workflow.Name, 398 | workflow.Path, 399 | }) 400 | } 401 | 402 | sort.SliceStable(rows, func(i, j int) bool { 403 | return rows[i][0] < rows[j][0] 404 | }) 405 | 406 | m.tableTriggerableWorkflow.SetRows(rows) 407 | if len(rows) > 0 { 408 | m.tableTriggerableWorkflow.SetCursor(0) 409 | } 410 | } 411 | 412 | func (m *ModelGithubWorkflow) finalizeUpdate() { 413 | m.tableReady = true 414 | m.status.SetSuccessMessage(fmt.Sprintf("[%s@%s] Triggerable workflows fetched.", 415 | m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) 416 | } 417 | 418 | func (m *ModelGithubWorkflow) handleTableInputs() { 419 | if !m.tableReady { 420 | return 421 | } 422 | 423 | rows := m.tableTriggerableWorkflow.Rows() 424 | selectedRow := m.tableTriggerableWorkflow.SelectedRow() 425 | if len(rows) > 0 && len(selectedRow) > 0 { 426 | m.selectedRepository.WorkflowName = selectedRow[1] 427 | } 428 | } 429 | 430 | // ----------------------------------------------------------------------------- 431 | // UI Rendering 432 | // ----------------------------------------------------------------------------- 433 | 434 | func (m *ModelGithubWorkflow) renderTable() string { 435 | style := lipgloss.NewStyle(). 436 | BorderStyle(lipgloss.NormalBorder()). 437 | BorderForeground(lipgloss.Color("#3b698f")). 438 | MarginLeft(1) 439 | 440 | m.updateTableDimensions() 441 | return style.Render(m.tableTriggerableWorkflow.View()) 442 | } 443 | 444 | func (m *ModelGithubWorkflow) renderBranchInput() string { 445 | style := lipgloss.NewStyle(). 446 | Border(lipgloss.NormalBorder()). 447 | BorderForeground(lipgloss.Color("#3b698f")). 448 | Padding(0, 1). 449 | Width(m.skeleton.GetTerminalWidth() - 6). 450 | MarginLeft(1) 451 | 452 | if len(m.textInput.AvailableSuggestions()) > 0 && m.textInput.Value() == "" { 453 | if !m.state.Repository.HasFlows { 454 | m.textInput.Placeholder = "Branch selection disabled - No triggerable workflows available" 455 | } else { 456 | m.textInput.Placeholder = fmt.Sprintf("Type to switch branch (default: %s)", m.state.Repository.Branch) 457 | } 458 | } 459 | 460 | return style.Render(m.textInput.View()) 461 | } 462 | 463 | func (m *ModelGithubWorkflow) renderHelp() string { 464 | helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) 465 | return helpStyle.Render(m.help.View(m.keys)) 466 | } 467 | 468 | func (m *ModelGithubWorkflow) updateTableDimensions() { 469 | termWidth := m.skeleton.GetTerminalWidth() 470 | termHeight := m.skeleton.GetTerminalHeight() 471 | 472 | var tableWidth int 473 | for _, t := range tableColumnsWorkflow { 474 | tableWidth += t.Width 475 | } 476 | 477 | newTableColumns := tableColumnsWorkflow 478 | widthDiff := termWidth - tableWidth 479 | if widthDiff > 0 { 480 | newTableColumns[1].Width += widthDiff - 10 481 | m.tableTriggerableWorkflow.SetColumns(newTableColumns) 482 | m.tableTriggerableWorkflow.SetHeight(termHeight - 17) 483 | } 484 | } 485 | 486 | // ----------------------------------------------------------------------------- 487 | // Tab Management 488 | // ----------------------------------------------------------------------------- 489 | 490 | func (m *ModelGithubWorkflow) updateTabState() { 491 | if !m.state.Repository.HasFlows { 492 | m.skeleton.LockTab("trigger") 493 | return 494 | } 495 | m.skeleton.UnlockTabs() 496 | } 497 | -------------------------------------------------------------------------------- /internal/terminal/handler/ghworkflowhistory.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/table" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | "github.com/termkit/gama/internal/config" 15 | gu "github.com/termkit/gama/internal/github/usecase" 16 | "github.com/termkit/gama/pkg/browser" 17 | "github.com/termkit/skeleton" 18 | ) 19 | 20 | // ----------------------------------------------------------------------------- 21 | // Model Definition 22 | // ----------------------------------------------------------------------------- 23 | 24 | type ModelGithubWorkflowHistory struct { 25 | // Core dependencies 26 | skeleton *skeleton.Skeleton 27 | github gu.UseCase 28 | 29 | // UI Components 30 | Help help.Model 31 | keys githubWorkflowHistoryKeyMap 32 | tableWorkflowHistory table.Model 33 | status *ModelStatus 34 | modelTabOptions *ModelTabOptions 35 | 36 | // Table state 37 | tableReady bool 38 | tableStyle lipgloss.Style 39 | workflows []gu.Workflow 40 | lastRepository string 41 | 42 | // Live mode state 43 | liveMode bool 44 | liveModeInterval time.Duration 45 | 46 | // Workflow state 47 | selectedWorkflowID int64 48 | 49 | // Context management 50 | syncWorkflowHistoryContext context.Context 51 | cancelSyncWorkflowHistory context.CancelFunc 52 | 53 | // Shared state 54 | selectedRepository *SelectedRepository 55 | } 56 | 57 | type workflowHistoryUpdateMsg struct { 58 | UpdateAfter time.Duration 59 | } 60 | 61 | // ----------------------------------------------------------------------------- 62 | // Constructor & Initialization 63 | // ----------------------------------------------------------------------------- 64 | 65 | func SetupModelGithubWorkflowHistory(s *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubWorkflowHistory { 66 | cfg, err := config.LoadConfig() 67 | if err != nil { 68 | panic(fmt.Sprintf("failed to load config: %v", err)) 69 | } 70 | 71 | modelStatus := SetupModelStatus(s) 72 | tabOptions := NewOptions(s, modelStatus) 73 | m := &ModelGithubWorkflowHistory{ 74 | // Initialize core dependencies 75 | skeleton: s, 76 | github: githubUseCase, 77 | 78 | // Initialize UI components 79 | Help: help.New(), 80 | keys: githubWorkflowHistoryKeys, 81 | status: modelStatus, 82 | modelTabOptions: tabOptions, 83 | 84 | // Initialize state 85 | selectedRepository: NewSelectedRepository(), 86 | syncWorkflowHistoryContext: context.Background(), 87 | cancelSyncWorkflowHistory: func() {}, 88 | liveMode: cfg.Settings.LiveMode.Enabled, 89 | liveModeInterval: cfg.Settings.LiveMode.Interval, 90 | tableStyle: setupTableStyle(), 91 | } 92 | 93 | // Setup table 94 | m.tableWorkflowHistory = setupWorkflowHistoryTable() 95 | 96 | return m 97 | } 98 | 99 | func setupTableStyle() lipgloss.Style { 100 | return lipgloss.NewStyle(). 101 | BorderStyle(lipgloss.NormalBorder()). 102 | BorderForeground(lipgloss.Color("#3b698f")). 103 | MarginLeft(1) 104 | } 105 | 106 | func setupWorkflowHistoryTable() table.Model { 107 | t := table.New( 108 | table.WithColumns(tableColumnsWorkflowHistory), 109 | table.WithRows([]table.Row{}), 110 | table.WithFocused(true), 111 | table.WithHeight(7), 112 | ) 113 | 114 | // Apply styles 115 | s := table.DefaultStyles() 116 | s.Header = s.Header. 117 | BorderStyle(lipgloss.NormalBorder()). 118 | BorderForeground(lipgloss.Color("240")). 119 | BorderBottom(true). 120 | Bold(false) 121 | s.Selected = s.Selected. 122 | Foreground(lipgloss.Color("229")). 123 | Background(lipgloss.Color("57")). 124 | Bold(false) 125 | t.SetStyles(s) 126 | 127 | t.KeyMap = table.KeyMap{ 128 | LineUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), 129 | LineDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), 130 | PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), 131 | PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), 132 | GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "go to start")), 133 | GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "go to end")), 134 | } 135 | 136 | return t 137 | } 138 | 139 | // ----------------------------------------------------------------------------- 140 | // Bubbletea Model Implementation 141 | // ----------------------------------------------------------------------------- 142 | 143 | func (m *ModelGithubWorkflowHistory) Init() tea.Cmd { 144 | m.setupOptions() 145 | m.startLiveMode() 146 | return tea.Batch(m.modelTabOptions.Init()) 147 | } 148 | 149 | func (m *ModelGithubWorkflowHistory) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 150 | m.handleRepositoryChange() 151 | 152 | cursor := m.tableWorkflowHistory.Cursor() 153 | if m.workflows != nil && cursor >= 0 && cursor < len(m.workflows) { 154 | m.selectedWorkflowID = m.workflows[cursor].ID 155 | } 156 | 157 | var cmds []tea.Cmd 158 | var cmd tea.Cmd 159 | 160 | // Handle different message types 161 | switch msg := msg.(type) { 162 | case tea.KeyMsg: 163 | cmd = m.handleKeyMsg(msg) 164 | if cmd != nil { 165 | cmds = append(cmds, cmd) 166 | } 167 | case workflowHistoryUpdateMsg: 168 | cmd = m.handleUpdateMsg(msg) 169 | if cmd != nil { 170 | cmds = append(cmds, cmd) 171 | } 172 | } 173 | 174 | // Update UI components 175 | if cmd = m.updateUIComponents(msg); cmd != nil { 176 | cmds = append(cmds, m.updateUIComponents(msg)) 177 | } 178 | 179 | return m, tea.Batch(cmds...) 180 | } 181 | 182 | func (m *ModelGithubWorkflowHistory) View() string { 183 | return lipgloss.JoinVertical(lipgloss.Top, 184 | m.renderTable(), 185 | m.modelTabOptions.View(), 186 | m.status.View(), 187 | m.renderHelp(), 188 | ) 189 | } 190 | 191 | // ----------------------------------------------------------------------------- 192 | // Event Handlers 193 | // ----------------------------------------------------------------------------- 194 | 195 | func (m *ModelGithubWorkflowHistory) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { 196 | switch { 197 | case key.Matches(msg, m.keys.Refresh): 198 | go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) 199 | return nil 200 | case key.Matches(msg, m.keys.LiveMode): 201 | return m.toggleLiveMode() 202 | } 203 | return nil 204 | } 205 | 206 | func (m *ModelGithubWorkflowHistory) handleUpdateMsg(msg workflowHistoryUpdateMsg) tea.Cmd { 207 | go func() { 208 | time.Sleep(msg.UpdateAfter) 209 | m.syncWorkflowHistory(m.syncWorkflowHistoryContext) 210 | m.skeleton.TriggerUpdate() 211 | }() 212 | return nil 213 | } 214 | 215 | // ----------------------------------------------------------------------------- 216 | // Live Mode Management 217 | // ----------------------------------------------------------------------------- 218 | 219 | func (m *ModelGithubWorkflowHistory) startLiveMode() { 220 | ticker := time.NewTicker(m.liveModeInterval) 221 | go func() { 222 | defer ticker.Stop() 223 | for { 224 | select { 225 | case <-ticker.C: 226 | if m.liveMode { 227 | m.skeleton.TriggerUpdateWithMsg(workflowHistoryUpdateMsg{ 228 | UpdateAfter: time.Nanosecond, 229 | }) 230 | } 231 | case <-m.syncWorkflowHistoryContext.Done(): 232 | return 233 | } 234 | } 235 | }() 236 | } 237 | 238 | func (m *ModelGithubWorkflowHistory) toggleLiveMode() tea.Cmd { 239 | m.liveMode = !m.liveMode 240 | 241 | status := "Off" 242 | message := "Live mode disabled" 243 | 244 | if m.liveMode { 245 | status = "On" 246 | message = "Live mode enabled" 247 | // Trigger immediate update when enabling 248 | go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) 249 | } 250 | 251 | m.status.SetSuccessMessage(message) 252 | m.skeleton.UpdateWidgetValue("live", fmt.Sprintf("Live Mode: %s", status)) 253 | 254 | return nil 255 | } 256 | 257 | // ----------------------------------------------------------------------------- 258 | // Repository Change Handling 259 | // ----------------------------------------------------------------------------- 260 | 261 | func (m *ModelGithubWorkflowHistory) handleRepositoryChange() tea.Cmd { 262 | if m.lastRepository == m.selectedRepository.RepositoryName { 263 | return nil 264 | } 265 | 266 | if m.cancelSyncWorkflowHistory != nil { 267 | m.cancelSyncWorkflowHistory() 268 | } 269 | 270 | m.lastRepository = m.selectedRepository.RepositoryName 271 | m.syncWorkflowHistoryContext, m.cancelSyncWorkflowHistory = context.WithCancel(context.Background()) 272 | 273 | go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) 274 | return nil 275 | } 276 | 277 | // ----------------------------------------------------------------------------- 278 | // Workflow History Sync 279 | // ----------------------------------------------------------------------------- 280 | 281 | func (m *ModelGithubWorkflowHistory) syncWorkflowHistory(ctx context.Context) { 282 | defer m.skeleton.TriggerUpdate() 283 | 284 | // Add timeout to prevent hanging 285 | ctx, cancel := context.WithTimeout(ctx, 30*time.Second) 286 | defer cancel() 287 | 288 | m.initializeSyncState() 289 | 290 | // Check context before proceeding 291 | if ctx.Err() != nil { 292 | m.status.SetDefaultMessage("Operation cancelled") 293 | return 294 | } 295 | 296 | workflowHistory, err := m.fetchWorkflowHistory(ctx) 297 | if err != nil { 298 | m.handleFetchError(err) 299 | return 300 | } 301 | 302 | m.processWorkflowHistory(workflowHistory) 303 | } 304 | 305 | func (m *ModelGithubWorkflowHistory) handleFetchError(err error) { 306 | switch { 307 | case errors.Is(err, context.Canceled): 308 | m.status.SetDefaultMessage("Workflow history fetch cancelled") 309 | case errors.Is(err, context.DeadlineExceeded): 310 | m.status.SetErrorMessage("Workflow history fetch timed out") 311 | default: 312 | m.status.SetError(err) 313 | m.status.SetErrorMessage(fmt.Sprintf("Failed to fetch workflow history: %v", err)) 314 | } 315 | } 316 | 317 | func (m *ModelGithubWorkflowHistory) initializeSyncState() { 318 | m.tableReady = false 319 | m.status.Reset() 320 | m.status.SetProgressMessage(fmt.Sprintf("[%s] Fetching workflow history...", m.selectedRepository.RepositoryName)) 321 | m.modelTabOptions.SetStatus(StatusWait) 322 | m.clearWorkflowHistory() 323 | } 324 | 325 | func (m *ModelGithubWorkflowHistory) clearWorkflowHistory() { 326 | m.tableWorkflowHistory.SetRows([]table.Row{}) 327 | m.workflows = nil 328 | } 329 | 330 | func (m *ModelGithubWorkflowHistory) fetchWorkflowHistory(ctx context.Context) (*gu.GetWorkflowHistoryOutput, error) { 331 | history, err := m.github.GetWorkflowHistory(ctx, gu.GetWorkflowHistoryInput{ 332 | Repository: m.selectedRepository.RepositoryName, 333 | Branch: m.selectedRepository.BranchName, 334 | }) 335 | 336 | if err != nil { 337 | if !errors.Is(err, context.Canceled) { 338 | m.status.SetError(err) 339 | m.status.SetErrorMessage("Workflow history cannot be listed") 340 | } 341 | return nil, err 342 | } 343 | 344 | return history, nil 345 | } 346 | 347 | // ----------------------------------------------------------------------------- 348 | // Workflow History Processing 349 | // ----------------------------------------------------------------------------- 350 | 351 | func (m *ModelGithubWorkflowHistory) processWorkflowHistory(history *gu.GetWorkflowHistoryOutput) { 352 | if len(history.Workflows) == 0 { 353 | m.handleEmptyWorkflowHistory() 354 | return 355 | } 356 | 357 | m.workflows = history.Workflows 358 | m.updateWorkflowTable() 359 | m.finalizeUpdate() 360 | } 361 | 362 | func (m *ModelGithubWorkflowHistory) handleEmptyWorkflowHistory() { 363 | m.modelTabOptions.SetStatus(StatusNone) 364 | m.status.SetDefaultMessage(fmt.Sprintf("[%s] No workflow history found.", 365 | m.selectedRepository.RepositoryName)) 366 | } 367 | 368 | func (m *ModelGithubWorkflowHistory) updateWorkflowTable() { 369 | rows := make([]table.Row, 0, len(m.workflows)) 370 | for _, workflow := range m.workflows { 371 | rows = append(rows, table.Row{ 372 | workflow.WorkflowName, 373 | workflow.ActionName, 374 | workflow.TriggeredBy, 375 | workflow.StartedAt, 376 | workflow.Status, 377 | workflow.Duration, 378 | }) 379 | } 380 | m.tableWorkflowHistory.SetRows(rows) 381 | } 382 | 383 | func (m *ModelGithubWorkflowHistory) finalizeUpdate() { 384 | m.tableReady = true 385 | m.tableWorkflowHistory.SetCursor(0) 386 | m.modelTabOptions.SetStatus(StatusIdle) 387 | m.status.SetSuccessMessage(fmt.Sprintf("[%s] Workflow history fetched.", m.selectedRepository.RepositoryName)) 388 | } 389 | 390 | // ----------------------------------------------------------------------------- 391 | // UI Component Updates 392 | // ----------------------------------------------------------------------------- 393 | 394 | func (m *ModelGithubWorkflowHistory) updateUIComponents(msg tea.Msg) tea.Cmd { 395 | var cmds []tea.Cmd 396 | var cmd tea.Cmd 397 | 398 | // Update table and handle navigation 399 | m.tableWorkflowHistory, cmd = m.tableWorkflowHistory.Update(msg) 400 | cmds = append(cmds, cmd) 401 | 402 | // Update tab options 403 | m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) 404 | cmds = append(cmds, cmd) 405 | 406 | return tea.Batch(cmds...) 407 | } 408 | 409 | // ----------------------------------------------------------------------------- 410 | // UI Rendering 411 | // ----------------------------------------------------------------------------- 412 | 413 | func (m *ModelGithubWorkflowHistory) renderTable() string { 414 | m.updateTableDimensions() 415 | return m.tableStyle.Render(m.tableWorkflowHistory.View()) 416 | } 417 | 418 | func (m *ModelGithubWorkflowHistory) renderHelp() string { 419 | helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) 420 | return helpStyle.Render(m.ViewHelp()) 421 | } 422 | 423 | func (m *ModelGithubWorkflowHistory) updateTableDimensions() { 424 | const ( 425 | minTableWidth = 80 // Minimum width to maintain readability 426 | tablePadding = 18 // Account for borders and margins 427 | minColumnWidth = 10 // Minimum width for any column 428 | ) 429 | 430 | termWidth := m.skeleton.GetTerminalWidth() 431 | termHeight := m.skeleton.GetTerminalHeight() 432 | 433 | if termWidth <= minTableWidth { 434 | return // Prevent table from becoming too narrow 435 | } 436 | 437 | var tableWidth int 438 | for _, t := range tableColumnsWorkflowHistory { 439 | tableWidth += t.Width 440 | } 441 | 442 | newTableColumns := make([]table.Column, len(tableColumnsWorkflowHistory)) 443 | copy(newTableColumns, tableColumnsWorkflowHistory) 444 | 445 | widthDiff := termWidth - tableWidth - tablePadding 446 | if widthDiff > 0 { 447 | // Distribute extra width between workflow name and action name columns 448 | extraWidth := widthDiff / 2 449 | newTableColumns[0].Width = max(newTableColumns[0].Width+extraWidth, minColumnWidth) 450 | newTableColumns[1].Width = max(newTableColumns[1].Width+extraWidth, minColumnWidth) 451 | 452 | m.tableWorkflowHistory.SetColumns(newTableColumns) 453 | } 454 | 455 | // Ensure reasonable table height 456 | maxHeight := termHeight - 17 457 | if maxHeight > 0 { 458 | m.tableWorkflowHistory.SetHeight(maxHeight) 459 | } 460 | } 461 | 462 | // ----------------------------------------------------------------------------- 463 | // Option Management 464 | // ----------------------------------------------------------------------------- 465 | 466 | func (m *ModelGithubWorkflowHistory) setupOptions() { 467 | m.modelTabOptions.AddOption("Open in browser", m.openInBrowser) 468 | m.modelTabOptions.AddOption("Rerun failed jobs", m.rerunFailedJobs) 469 | m.modelTabOptions.AddOption("Rerun workflow", m.rerunWorkflow) 470 | m.modelTabOptions.AddOption("Cancel workflow", m.cancelWorkflow) 471 | } 472 | 473 | func (m *ModelGithubWorkflowHistory) openInBrowser() { 474 | m.status.SetProgressMessage("Opening in browser...") 475 | 476 | url := fmt.Sprintf("https://github.com/%s/actions/runs/%d", 477 | m.selectedRepository.RepositoryName, 478 | m.selectedWorkflowID) 479 | 480 | if err := browser.OpenInBrowser(url); err != nil { 481 | m.status.SetError(err) 482 | m.status.SetErrorMessage("Failed to open in browser") 483 | return 484 | } 485 | m.status.SetSuccessMessage("Opened in browser") 486 | } 487 | 488 | func (m *ModelGithubWorkflowHistory) rerunFailedJobs() { 489 | m.status.SetProgressMessage("Re-running failed jobs...") 490 | 491 | if err := m.github.ReRunFailedJobs(context.Background(), gu.ReRunFailedJobsInput{ 492 | Repository: m.selectedRepository.RepositoryName, 493 | WorkflowID: m.selectedWorkflowID, 494 | }); err != nil { 495 | m.status.SetError(err) 496 | m.status.SetErrorMessage("Failed to re-run failed jobs") 497 | return 498 | } 499 | 500 | m.status.SetSuccessMessage("Re-ran failed jobs") 501 | } 502 | 503 | func (m *ModelGithubWorkflowHistory) rerunWorkflow() { 504 | if m.selectedWorkflowID == 0 { 505 | m.status.SetErrorMessage("No workflow selected") 506 | return 507 | } 508 | 509 | m.status.SetProgressMessage("Re-running workflow...") 510 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 511 | defer cancel() 512 | 513 | if err := m.github.ReRunWorkflow(ctx, gu.ReRunWorkflowInput{ 514 | Repository: m.selectedRepository.RepositoryName, 515 | WorkflowID: m.selectedWorkflowID, 516 | }); err != nil { 517 | if errors.Is(err, context.DeadlineExceeded) { 518 | m.status.SetErrorMessage("Workflow re-run request timed out") 519 | } else { 520 | m.status.SetError(err) 521 | m.status.SetErrorMessage(fmt.Sprintf("Failed to re-run workflow: %v", err)) 522 | } 523 | return 524 | } 525 | 526 | m.status.SetSuccessMessage("Workflow re-run initiated") 527 | // Trigger refresh after short delay to show updated status 528 | go func() { 529 | time.Sleep(2 * time.Second) 530 | m.syncWorkflowHistory(m.syncWorkflowHistoryContext) 531 | }() 532 | } 533 | 534 | func (m *ModelGithubWorkflowHistory) cancelWorkflow() { 535 | m.status.SetProgressMessage("Canceling workflow...") 536 | 537 | if err := m.github.CancelWorkflow(context.Background(), gu.CancelWorkflowInput{ 538 | Repository: m.selectedRepository.RepositoryName, 539 | WorkflowID: m.selectedWorkflowID, 540 | }); err != nil { 541 | m.status.SetError(err) 542 | m.status.SetErrorMessage("Failed to cancel workflow") 543 | return 544 | } 545 | 546 | m.status.SetSuccessMessage("Canceled workflow") 547 | } 548 | -------------------------------------------------------------------------------- /internal/terminal/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/termkit/gama/internal/config" 7 | gu "github.com/termkit/gama/internal/github/usecase" 8 | pkgversion "github.com/termkit/gama/pkg/version" 9 | "github.com/termkit/skeleton" 10 | ) 11 | 12 | func SetupTerminal(githubUseCase gu.UseCase, version pkgversion.Version) tea.Model { 13 | cfg, err := config.LoadConfig() 14 | if err != nil { 15 | panic(fmt.Sprintf("failed to load config: %v", err)) 16 | } 17 | 18 | s := skeleton.NewSkeleton() 19 | 20 | s.AddPage("info", "Info", SetupModelInfo(s, githubUseCase, version)) 21 | s.AddPage("repository", "Repository", SetupModelGithubRepository(s, githubUseCase)) 22 | s.AddPage("history", "Workflow History", SetupModelGithubWorkflowHistory(s, githubUseCase)) 23 | s.AddPage("workflow", "Workflow", SetupModelGithubWorkflow(s, githubUseCase)) 24 | s.AddPage("trigger", "Trigger", SetupModelGithubTrigger(s, githubUseCase)) 25 | 26 | s.SetBorderColor("#ff0055"). 27 | SetActiveTabBorderColor("#ff0055"). 28 | SetInactiveTabBorderColor("#82636f"). 29 | SetWidgetBorderColor("#ff0055") 30 | 31 | if cfg.Settings.LiveMode.Enabled { 32 | s.AddWidget("live", "Live Mode: On") 33 | } else { 34 | s.AddWidget("live", "Live Mode: Off") 35 | } 36 | 37 | s.SetTerminalViewportWidth(MinTerminalWidth) 38 | s.SetTerminalViewportHeight(MinTerminalHeight) 39 | 40 | s.KeyMap.SetKeyNextTab(handlerKeys.SwitchTabRight) 41 | s.KeyMap.SetKeyPrevTab(handlerKeys.SwitchTabLeft) 42 | s.KeyMap.SetKeyQuit(handlerKeys.Quit) 43 | 44 | return s 45 | } 46 | -------------------------------------------------------------------------------- /internal/terminal/handler/keymap.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/termkit/gama/internal/config" 7 | 8 | teakey "github.com/charmbracelet/bubbles/key" 9 | ) 10 | 11 | func loadConfig() *config.Config { 12 | cfg, err := config.LoadConfig() 13 | if err != nil { 14 | panic(fmt.Sprintf("failed to load config: %v", err)) 15 | } 16 | return cfg 17 | } 18 | 19 | // --------------------------------------------------------------------------- 20 | 21 | type handlerKeyMap struct { 22 | SwitchTabRight teakey.Binding 23 | SwitchTabLeft teakey.Binding 24 | Quit teakey.Binding 25 | } 26 | 27 | var handlerKeys = func() handlerKeyMap { 28 | cfg := loadConfig() 29 | 30 | return handlerKeyMap{ 31 | SwitchTabRight: teakey.NewBinding( 32 | teakey.WithKeys(cfg.Shortcuts.SwitchTabRight), 33 | ), 34 | SwitchTabLeft: teakey.NewBinding( 35 | teakey.WithKeys(cfg.Shortcuts.SwitchTabLeft), 36 | ), 37 | Quit: teakey.NewBinding( 38 | teakey.WithKeys(cfg.Shortcuts.Quit), 39 | ), 40 | } 41 | }() 42 | 43 | // --------------------------------------------------------------------------- 44 | 45 | type githubInformationKeyMap struct { 46 | SwitchTabRight teakey.Binding 47 | Quit teakey.Binding 48 | } 49 | 50 | func (k githubInformationKeyMap) ShortHelp() []teakey.Binding { 51 | return []teakey.Binding{k.SwitchTabRight, k.Quit} 52 | } 53 | 54 | func (k githubInformationKeyMap) FullHelp() [][]teakey.Binding { 55 | return [][]teakey.Binding{ 56 | {k.SwitchTabRight}, 57 | {k.Quit}, 58 | } 59 | } 60 | 61 | var githubInformationKeys = func() githubInformationKeyMap { 62 | cfg := loadConfig() 63 | 64 | switchTabRight := cfg.Shortcuts.SwitchTabRight 65 | 66 | return githubInformationKeyMap{ 67 | SwitchTabRight: teakey.NewBinding( 68 | teakey.WithKeys(""), // help-only binding 69 | teakey.WithHelp(switchTabRight, "next tab"), 70 | ), 71 | Quit: teakey.NewBinding( 72 | teakey.WithKeys("q", cfg.Shortcuts.Quit), 73 | teakey.WithHelp("q", "quit"), 74 | ), 75 | } 76 | }() 77 | 78 | func (m *ModelInfo) ViewHelp() string { 79 | return m.help.View(m.keys) 80 | } 81 | 82 | // --------------------------------------------------------------------------- 83 | 84 | type githubRepositoryKeyMap struct { 85 | Refresh teakey.Binding 86 | SwitchTab teakey.Binding 87 | } 88 | 89 | func (k githubRepositoryKeyMap) ShortHelp() []teakey.Binding { 90 | return []teakey.Binding{k.SwitchTab, k.Refresh} 91 | } 92 | 93 | func (k githubRepositoryKeyMap) FullHelp() [][]teakey.Binding { 94 | return [][]teakey.Binding{ 95 | {k.SwitchTab}, 96 | {k.Refresh}, 97 | } 98 | } 99 | 100 | var githubRepositoryKeys = func() githubRepositoryKeyMap { 101 | cfg := loadConfig() 102 | 103 | var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) 104 | 105 | return githubRepositoryKeyMap{ 106 | Refresh: teakey.NewBinding( 107 | teakey.WithKeys(cfg.Shortcuts.Refresh), 108 | teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), 109 | ), 110 | SwitchTab: teakey.NewBinding( 111 | teakey.WithKeys(""), // help-only binding 112 | teakey.WithHelp(tabSwitch, "switch tab"), 113 | ), 114 | } 115 | }() 116 | 117 | func (m *ModelGithubRepository) ViewHelp() string { 118 | return m.help.View(m.Keys) 119 | } 120 | 121 | // --------------------------------------------------------------------------- 122 | 123 | type githubWorkflowHistoryKeyMap struct { 124 | Refresh teakey.Binding 125 | SwitchTab teakey.Binding 126 | LiveMode teakey.Binding 127 | } 128 | 129 | func (k githubWorkflowHistoryKeyMap) ShortHelp() []teakey.Binding { 130 | return []teakey.Binding{k.SwitchTab, k.Refresh, k.LiveMode} 131 | } 132 | 133 | func (k githubWorkflowHistoryKeyMap) FullHelp() [][]teakey.Binding { 134 | return [][]teakey.Binding{ 135 | {k.SwitchTab}, 136 | {k.Refresh}, 137 | {k.LiveMode}, 138 | } 139 | } 140 | 141 | var githubWorkflowHistoryKeys = func() githubWorkflowHistoryKeyMap { 142 | cfg := loadConfig() 143 | 144 | var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) 145 | 146 | return githubWorkflowHistoryKeyMap{ 147 | Refresh: teakey.NewBinding( 148 | teakey.WithKeys(cfg.Shortcuts.Refresh), 149 | teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), 150 | ), 151 | LiveMode: teakey.NewBinding( 152 | teakey.WithKeys(cfg.Shortcuts.LiveMode), 153 | teakey.WithHelp(cfg.Shortcuts.LiveMode, "Toggle live mode"), 154 | ), 155 | SwitchTab: teakey.NewBinding( 156 | teakey.WithKeys(""), // help-only binding 157 | teakey.WithHelp(tabSwitch, "switch tab"), 158 | ), 159 | } 160 | }() 161 | 162 | func (m *ModelGithubWorkflowHistory) ViewHelp() string { 163 | return m.Help.View(m.keys) 164 | } 165 | 166 | // --------------------------------------------------------------------------- 167 | 168 | type githubWorkflowKeyMap struct { 169 | SwitchTab teakey.Binding 170 | } 171 | 172 | func (k githubWorkflowKeyMap) ShortHelp() []teakey.Binding { 173 | return []teakey.Binding{k.SwitchTab} 174 | } 175 | 176 | func (k githubWorkflowKeyMap) FullHelp() [][]teakey.Binding { 177 | return [][]teakey.Binding{ 178 | {k.SwitchTab}, 179 | } 180 | } 181 | 182 | var githubWorkflowKeys = func() githubWorkflowKeyMap { 183 | cfg := loadConfig() 184 | 185 | var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) 186 | 187 | return githubWorkflowKeyMap{ 188 | SwitchTab: teakey.NewBinding( 189 | teakey.WithKeys(""), // help-only binding 190 | teakey.WithHelp(tabSwitch, "switch tab"), 191 | ), 192 | } 193 | }() 194 | 195 | func (m *ModelGithubWorkflow) ViewHelp() string { 196 | return m.help.View(m.keys) 197 | } 198 | 199 | // --------------------------------------------------------------------------- 200 | 201 | type githubTriggerKeyMap struct { 202 | SwitchTabLeft teakey.Binding 203 | SwitchTab teakey.Binding 204 | Trigger teakey.Binding 205 | Refresh teakey.Binding 206 | } 207 | 208 | func (k githubTriggerKeyMap) ShortHelp() []teakey.Binding { 209 | return []teakey.Binding{k.SwitchTabLeft, k.Refresh, k.SwitchTab, k.Trigger} 210 | } 211 | 212 | func (k githubTriggerKeyMap) FullHelp() [][]teakey.Binding { 213 | return [][]teakey.Binding{ 214 | {k.SwitchTabLeft}, 215 | {k.Refresh}, 216 | {k.SwitchTab}, 217 | {k.Trigger}, 218 | } 219 | } 220 | 221 | var githubTriggerKeys = func() githubTriggerKeyMap { 222 | cfg := loadConfig() 223 | 224 | previousTab := cfg.Shortcuts.SwitchTabLeft 225 | 226 | return githubTriggerKeyMap{ 227 | SwitchTabLeft: teakey.NewBinding( 228 | teakey.WithKeys(""), // help-only binding 229 | teakey.WithHelp(previousTab, "previous tab"), 230 | ), 231 | Refresh: teakey.NewBinding( 232 | teakey.WithKeys(cfg.Shortcuts.Refresh), 233 | teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), 234 | ), 235 | SwitchTab: teakey.NewBinding( 236 | teakey.WithKeys(cfg.Shortcuts.Tab), 237 | teakey.WithHelp(cfg.Shortcuts.Tab, "switch button"), 238 | ), 239 | Trigger: teakey.NewBinding( 240 | teakey.WithKeys(cfg.Shortcuts.Enter), 241 | teakey.WithHelp(cfg.Shortcuts.Enter, "trigger workflow"), 242 | ), 243 | } 244 | }() 245 | 246 | func (m *ModelGithubTrigger) ViewHelp() string { 247 | return m.help.View(m.Keys) 248 | } 249 | -------------------------------------------------------------------------------- /internal/terminal/handler/status.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/termkit/skeleton" 9 | ) 10 | 11 | type ModelStatus struct { 12 | skeleton *skeleton.Skeleton 13 | 14 | // err is hold the error 15 | err error 16 | 17 | // errorMessage is hold the error message 18 | errorMessage string 19 | 20 | // message is hold the message, if there is no error 21 | message string 22 | 23 | // messageType is hold the message type 24 | messageType MessageType 25 | } 26 | 27 | type MessageType string 28 | 29 | const ( 30 | // MessageTypeDefault is the message type for default 31 | MessageTypeDefault MessageType = "default" 32 | 33 | // MessageTypeProgress is the message type for progress 34 | MessageTypeProgress MessageType = "progress" 35 | 36 | // MessageTypeSuccess is the message type for success 37 | MessageTypeSuccess MessageType = "success" 38 | ) 39 | 40 | func SetupModelStatus(skeleton *skeleton.Skeleton) *ModelStatus { 41 | return &ModelStatus{ 42 | skeleton: skeleton, 43 | err: nil, 44 | errorMessage: "", 45 | } 46 | } 47 | 48 | func (m *ModelStatus) View() string { 49 | var windowStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()) 50 | width := m.skeleton.GetTerminalWidth() - 4 51 | doc := strings.Builder{} 52 | 53 | if m.HaveError() { 54 | windowStyle = WindowStyleError.Width(width) 55 | doc.WriteString(windowStyle.Render(m.viewError())) 56 | return lipgloss.JoinHorizontal(lipgloss.Top, doc.String()) 57 | } 58 | 59 | switch m.messageType { 60 | case MessageTypeDefault: 61 | windowStyle = WindowStyleDefault.Width(width) 62 | case MessageTypeProgress: 63 | windowStyle = WindowStyleProgress.Width(width) 64 | case MessageTypeSuccess: 65 | windowStyle = WindowStyleSuccess.Width(width) 66 | default: 67 | windowStyle = WindowStyleDefault.Width(width) 68 | } 69 | 70 | doc.WriteString(windowStyle.Render(m.viewMessage())) 71 | return doc.String() 72 | } 73 | 74 | func (m *ModelStatus) SetError(err error) { 75 | m.err = err 76 | } 77 | 78 | func (m *ModelStatus) SetErrorMessage(message string) { 79 | m.errorMessage = message 80 | } 81 | 82 | func (m *ModelStatus) SetProgressMessage(message string) { 83 | m.messageType = MessageTypeProgress 84 | m.message = message 85 | } 86 | 87 | func (m *ModelStatus) SetSuccessMessage(message string) { 88 | m.messageType = MessageTypeSuccess 89 | m.message = message 90 | } 91 | 92 | func (m *ModelStatus) SetDefaultMessage(message string) { 93 | m.messageType = MessageTypeDefault 94 | m.message = message 95 | } 96 | 97 | func (m *ModelStatus) GetError() error { 98 | return m.err 99 | } 100 | 101 | func (m *ModelStatus) GetErrorMessage() string { 102 | return m.errorMessage 103 | } 104 | 105 | func (m *ModelStatus) GetMessage() string { 106 | return m.message 107 | } 108 | 109 | func (m *ModelStatus) ResetError() { 110 | m.err = nil 111 | m.errorMessage = "" 112 | } 113 | 114 | func (m *ModelStatus) ResetMessage() { 115 | m.message = "" 116 | } 117 | 118 | func (m *ModelStatus) Reset() { 119 | m.ResetError() 120 | m.ResetMessage() 121 | } 122 | 123 | func (m *ModelStatus) HaveError() bool { 124 | return m.err != nil 125 | } 126 | 127 | func (m *ModelStatus) viewError() string { 128 | return fmt.Sprintf("[%v]", m.err) 129 | } 130 | 131 | func (m *ModelStatus) viewMessage() string { 132 | doc := strings.Builder{} 133 | doc.WriteString(m.message) 134 | return doc.String() 135 | } 136 | -------------------------------------------------------------------------------- /internal/terminal/handler/table.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import "github.com/charmbracelet/bubbles/table" 4 | 5 | var tableColumnsGithubRepository = []table.Column{ 6 | {Title: "Repository", Width: 24}, 7 | {Title: "Default Branch", Width: 16}, 8 | {Title: "Stars", Width: 6}, 9 | {Title: "Workflows", Width: 9}, 10 | } 11 | 12 | // --------------------------------------------------------------------------- 13 | 14 | var tableColumnsTrigger = []table.Column{ 15 | {Title: "ID", Width: 2}, 16 | {Title: "Type", Width: 6}, 17 | {Title: "Key", Width: 24}, 18 | {Title: "Default", Width: 16}, 19 | {Title: "Value", Width: 44}, 20 | } 21 | 22 | // --------------------------------------------------------------------------- 23 | 24 | var tableColumnsWorkflow = []table.Column{ 25 | {Title: "Workflow", Width: 32}, 26 | {Title: "File", Width: 48}, 27 | } 28 | 29 | // --------------------------------------------------------------------------- 30 | 31 | var tableColumnsWorkflowHistory = []table.Column{ 32 | {Title: "Workflow", Width: 12}, 33 | {Title: "Commit Message", Width: 16}, 34 | {Title: "Triggered", Width: 12}, 35 | {Title: "Started At", Width: 19}, 36 | {Title: "Status", Width: 9}, 37 | {Title: "Duration", Width: 8}, 38 | } 39 | -------------------------------------------------------------------------------- /internal/terminal/handler/taboptions.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/termkit/skeleton" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | type ModelTabOptions struct { 15 | skeleton *skeleton.Skeleton 16 | 17 | status *ModelStatus 18 | previousStatus ModelStatus 19 | modelLock bool 20 | 21 | optionStatus OptionStatus 22 | 23 | options []string 24 | optionsAction []string 25 | 26 | optionsWithFunc map[int]func() 27 | 28 | timer int 29 | 30 | isTabSelected bool 31 | 32 | cursor int 33 | } 34 | 35 | type OptionStatus string 36 | 37 | const ( 38 | // StatusIdle is for when the options are ready to use 39 | StatusIdle OptionStatus = "Idle" 40 | 41 | // StatusWait is for when the options are not ready to use 42 | StatusWait OptionStatus = "Wait" 43 | 44 | // StatusNone is for when the options are not usable 45 | StatusNone OptionStatus = "None" 46 | ) 47 | 48 | func (o OptionStatus) String() string { 49 | return string(o) 50 | } 51 | 52 | func NewOptions(sk *skeleton.Skeleton, modelStatus *ModelStatus) *ModelTabOptions { 53 | var initialOptions = []string{ 54 | StatusWait.String(), 55 | } 56 | var initialOptionsAction = []string{ 57 | StatusWait.String(), 58 | } 59 | 60 | optionsWithFunc := make(map[int]func()) 61 | optionsWithFunc[0] = func() {} // NO OPERATION 62 | 63 | return &ModelTabOptions{ 64 | skeleton: sk, 65 | options: initialOptions, 66 | optionsAction: initialOptionsAction, 67 | optionsWithFunc: optionsWithFunc, 68 | optionStatus: StatusWait, 69 | status: modelStatus, 70 | } 71 | } 72 | 73 | func (o *ModelTabOptions) Init() tea.Cmd { 74 | return nil 75 | } 76 | 77 | func (o *ModelTabOptions) Update(msg tea.Msg) (*ModelTabOptions, tea.Cmd) { 78 | var cmd tea.Cmd 79 | 80 | if o.optionStatus == StatusWait || o.optionStatus == StatusNone { 81 | return o, cmd 82 | } 83 | 84 | switch msg := msg.(type) { 85 | case tea.KeyMsg: 86 | switch keypress := msg.String(); keypress { 87 | case "1": 88 | o.updateCursor(1) 89 | case "2": 90 | o.updateCursor(2) 91 | case "3": 92 | o.updateCursor(3) 93 | case "4": 94 | o.updateCursor(4) 95 | case "enter": 96 | o.executeOption() 97 | } 98 | } 99 | 100 | return o, cmd 101 | } 102 | 103 | func (o *ModelTabOptions) View() string { 104 | var b = lipgloss.RoundedBorder() 105 | b.Right = "├" 106 | b.Left = "┤" 107 | 108 | var style = lipgloss.NewStyle(). 109 | Foreground(lipgloss.Color("15")). 110 | Align(lipgloss.Center).Padding(0, 1, 0, 1). 111 | Border(b).Foreground(lipgloss.Color("15")) 112 | 113 | var opts []string 114 | opts = append(opts, " ") 115 | 116 | for i, option := range o.optionsAction { 117 | switch o.optionStatus { 118 | case StatusWait: 119 | style = style.BorderForeground(lipgloss.Color("208")) 120 | case StatusNone: 121 | style = style.BorderForeground(lipgloss.Color("240")) 122 | default: 123 | isActive := i == o.cursor 124 | 125 | if isActive { 126 | style = style.BorderForeground(lipgloss.Color("150")) 127 | } else { 128 | style = style.BorderForeground(lipgloss.Color("240")) 129 | } 130 | } 131 | opts = append(opts, style.Render(option)) 132 | } 133 | 134 | return lipgloss.JoinHorizontal(lipgloss.Top, opts...) 135 | } 136 | 137 | func (o *ModelTabOptions) resetOptionsWithOriginal() { 138 | if o.isTabSelected { 139 | return 140 | } 141 | o.isTabSelected = true 142 | o.timer = 3 143 | for o.timer >= 0 { 144 | o.optionsAction[0] = fmt.Sprintf("> %ds", o.timer) 145 | time.Sleep(1 * time.Second) 146 | o.timer-- 147 | o.skeleton.TriggerUpdate() 148 | } 149 | o.modelLock = false 150 | o.switchToPreviousError() 151 | o.optionsAction[0] = string(StatusIdle) 152 | o.cursor = 0 153 | o.isTabSelected = false 154 | } 155 | 156 | func (o *ModelTabOptions) updateCursor(cursor int) { 157 | if cursor < len(o.options) { 158 | o.cursor = cursor 159 | o.showAreYouSure() 160 | go o.resetOptionsWithOriginal() 161 | } 162 | } 163 | 164 | func (o *ModelTabOptions) SetStatus(status OptionStatus) { 165 | o.optionStatus = status 166 | o.options[0] = status.String() 167 | o.optionsAction[0] = status.String() 168 | } 169 | 170 | func (o *ModelTabOptions) AddOption(option string, action func()) { 171 | var optionWithNumber string 172 | var optionNumber = len(o.options) 173 | optionWithNumber = fmt.Sprintf("%d) %s", optionNumber, option) 174 | o.options = append(o.options, optionWithNumber) 175 | o.optionsAction = append(o.optionsAction, optionWithNumber) 176 | o.optionsWithFunc[optionNumber] = action 177 | } 178 | 179 | func (o *ModelTabOptions) getOptionMessage() string { 180 | option := o.options[o.cursor] 181 | option = strings.TrimPrefix(option, fmt.Sprintf("%d) ", o.cursor)) 182 | return option 183 | } 184 | 185 | func (o *ModelTabOptions) showAreYouSure() { 186 | var yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Blink(true) 187 | 188 | if !o.modelLock { 189 | o.previousStatus = *o.status 190 | o.modelLock = true 191 | } 192 | o.status.Reset() 193 | o.status.SetProgressMessage(fmt.Sprintf( 194 | "Are you sure you want to %s? %s", 195 | o.getOptionMessage(), 196 | yellowStyle.Render("[ Press Enter ]"), 197 | )) 198 | 199 | } 200 | 201 | func (o *ModelTabOptions) switchToPreviousError() { 202 | if o.modelLock { 203 | return 204 | } 205 | *o.status = o.previousStatus 206 | } 207 | 208 | func (o *ModelTabOptions) executeOption() { 209 | go o.optionsWithFunc[o.cursor]() 210 | o.cursor = 0 211 | o.timer = -1 212 | } 213 | -------------------------------------------------------------------------------- /internal/terminal/handler/types.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | "github.com/charmbracelet/bubbles/table" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | // SelectedRepository is a struct that holds the selected repository, workflow, and branch 12 | // It is a shared state between the different tabs 13 | type SelectedRepository struct { 14 | RepositoryName string 15 | WorkflowName string 16 | BranchName string 17 | } 18 | 19 | // Constants 20 | const ( 21 | MinTerminalWidth = 102 22 | MinTerminalHeight = 24 23 | ) 24 | 25 | // Styles 26 | var ( 27 | WindowStyleOrange = lipgloss.NewStyle().BorderForeground(lipgloss.Color("#ffaf00")).Border(lipgloss.RoundedBorder()) 28 | WindowStyleRed = lipgloss.NewStyle().BorderForeground(lipgloss.Color("9")).Border(lipgloss.RoundedBorder()) 29 | WindowStyleGreen = lipgloss.NewStyle().BorderForeground(lipgloss.Color("10")).Border(lipgloss.RoundedBorder()) 30 | WindowStyleGray = lipgloss.NewStyle().BorderForeground(lipgloss.Color("240")).Border(lipgloss.RoundedBorder()) 31 | WindowStyleWhite = lipgloss.NewStyle().BorderForeground(lipgloss.Color("255")).Border(lipgloss.RoundedBorder()) 32 | 33 | WindowStyleHelp = WindowStyleGray.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) 34 | WindowStyleError = WindowStyleRed.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) 35 | WindowStyleProgress = WindowStyleOrange.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) 36 | WindowStyleSuccess = WindowStyleGreen.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) 37 | WindowStyleDefault = WindowStyleWhite.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) 38 | ) 39 | 40 | // Constructor 41 | var ( 42 | onceSelectedRepository sync.Once 43 | selectedRepository *SelectedRepository 44 | ) 45 | 46 | func NewSelectedRepository() *SelectedRepository { 47 | onceSelectedRepository.Do(func() { 48 | selectedRepository = &SelectedRepository{} 49 | }) 50 | return selectedRepository 51 | } 52 | 53 | // Default table styles 54 | 55 | func defaultTableStyles() table.Styles { 56 | s := table.DefaultStyles() 57 | s.Header = s.Header. 58 | BorderStyle(lipgloss.NormalBorder()). 59 | BorderForeground(lipgloss.Color("240")). 60 | BorderBottom(true). 61 | Bold(false) 62 | s.Selected = s.Selected. 63 | Foreground(lipgloss.Color("229")). 64 | Background(lipgloss.Color("57")). 65 | Bold(false) 66 | return s 67 | } 68 | 69 | func defaultTableKeyMap() table.KeyMap { 70 | // We use "up" and "down" for both line up and down, we do not use "k" and "j" to prevent conflict with text input 71 | return table.KeyMap{ 72 | LineUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), 73 | LineDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), 74 | PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), 75 | PageDown: key.NewBinding(key.WithKeys("pgdown", " "), key.WithHelp("pgdn", "page down")), 76 | GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "go to start")), 77 | GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "go to end")), 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/termkit/gama/internal/config" 8 | gr "github.com/termkit/gama/internal/github/repository" 9 | gu "github.com/termkit/gama/internal/github/usecase" 10 | th "github.com/termkit/gama/internal/terminal/handler" 11 | pkgversion "github.com/termkit/gama/pkg/version" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | ) 15 | 16 | const ( 17 | repositoryOwner = "termkit" 18 | repositoryName = "gama" 19 | ) 20 | 21 | var Version = "under development" // will be set by build flag 22 | 23 | func main() { 24 | cfg, err := config.LoadConfig() 25 | if err != nil { 26 | panic(fmt.Sprintf("failed to load config: %v", err)) 27 | } 28 | 29 | version := pkgversion.New(repositoryOwner, repositoryName, Version) 30 | 31 | githubRepository := gr.New(cfg) 32 | githubUseCase := gu.New(githubRepository) 33 | 34 | terminal := th.SetupTerminal(githubUseCase, version) 35 | if _, err := tea.NewProgram(terminal).Run(); err != nil { 36 | fmt.Println("Error running program:", err) 37 | os.Exit(1) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/browser/browser.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "os/exec" 7 | "runtime" 8 | ) 9 | 10 | func OpenInBrowser(rawURL string) error { 11 | // Validate the URL to prevent command injection 12 | parsedURL, err := url.Parse(rawURL) 13 | if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { 14 | return errors.New("invalid URL") 15 | } 16 | 17 | var cmd string 18 | var args []string 19 | 20 | switch runtime.GOOS { 21 | case "windows": 22 | cmd = "cmd" 23 | args = []string{"/c", "start", parsedURL.String()} 24 | case "darwin": 25 | cmd = "open" 26 | args = []string{parsedURL.String()} 27 | default: // "linux", "freebsd", "openbsd", "netbsd" 28 | cmd = "xdg-open" 29 | args = []string{parsedURL.String()} 30 | } 31 | 32 | // #nosec G204 - URL is validated above and is safe to use 33 | return exec.Command(cmd, args...).Start() 34 | } 35 | -------------------------------------------------------------------------------- /pkg/version/httpclient.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | type HttpClient interface { 13 | Do(req *http.Request) (*http.Response, error) 14 | } 15 | 16 | func (v *version) do(ctx context.Context, requestBody any, responseBody any, requestOptions requestOptions) error { 17 | // Construct the request URL 18 | reqURL, err := url.Parse(requestOptions.path) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | // Add query parameters 24 | query := reqURL.Query() 25 | for key, value := range requestOptions.queryParams { 26 | query.Add(key, value) 27 | } 28 | reqURL.RawQuery = query.Encode() 29 | 30 | // Marshal the request body to JSON if accept/content type is JSON 31 | reqBody, err := requestBodyToJSON(requestBody, requestOptions) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // Create the HTTP request 37 | req, err := http.NewRequest(requestOptions.method, reqURL.String(), bytes.NewBuffer(reqBody)) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if requestOptions.contentType == "" { 43 | req.Header.Set("Content-Type", requestOptions.contentType) 44 | } 45 | if requestOptions.accept == "" { 46 | req.Header.Set("Accept", requestOptions.accept) 47 | } 48 | req.Header.Set("X-GitHub-Api-Version", "2022-11-28") 49 | req = req.WithContext(ctx) 50 | 51 | // Perform the HTTP request using the injected client 52 | resp, err := v.client.Do(req) 53 | if err != nil { 54 | return err 55 | } 56 | defer resp.Body.Close() 57 | 58 | var errorResponse struct { 59 | Message string `json:"message"` 60 | } 61 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 62 | // Decode the error response body 63 | err = json.NewDecoder(resp.Body).Decode(&errorResponse) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | return errors.New(errorResponse.Message) 69 | } 70 | 71 | // Decode the response body 72 | if responseBody != nil { 73 | if err = json.NewDecoder(resp.Body).Decode(responseBody); err != nil { 74 | return err 75 | } 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func requestBodyToJSON(requestBody any, requestOptions requestOptions) ([]byte, error) { 82 | if requestBody != nil { 83 | if requestOptions.accept == "application/json" || requestOptions.contentType == "application/json" { 84 | return json.Marshal(requestBody) 85 | } 86 | 87 | return []byte(requestBody.(string)), nil 88 | } 89 | 90 | return []byte{}, nil 91 | } 92 | 93 | type requestOptions struct { 94 | method string 95 | path string 96 | contentType string 97 | accept string 98 | queryParams map[string]string 99 | } 100 | -------------------------------------------------------------------------------- /pkg/version/ports.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "context" 4 | 5 | type Version interface { 6 | CurrentVersion() string 7 | LatestVersion(ctx context.Context) (string, error) 8 | IsUpdateAvailable(ctx context.Context) (isAvailable bool, version string, err error) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | 11 | "github.com/Masterminds/semver/v3" 12 | ) 13 | 14 | type version struct { 15 | client HttpClient 16 | 17 | repositoryOwner string 18 | repositoryName string 19 | 20 | currentVersion string 21 | latestVersion string 22 | } 23 | 24 | func New(repositoryOwner, repositoryName, currentVersion string) Version { 25 | return &version{ 26 | client: &http.Client{ 27 | Timeout: 20 * time.Second, 28 | }, 29 | repositoryOwner: repositoryOwner, 30 | repositoryName: repositoryName, 31 | currentVersion: currentVersion, 32 | } 33 | } 34 | 35 | func (v *version) CurrentVersion() string { 36 | return v.currentVersion 37 | } 38 | 39 | func (v *version) LatestVersion(ctx context.Context) (string, error) { 40 | var result struct { 41 | TagName string `json:"tag_name"` 42 | } 43 | 44 | err := v.do(ctx, nil, &result, requestOptions{ 45 | method: http.MethodGet, 46 | path: fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", v.repositoryOwner, v.repositoryName), 47 | accept: "application/vnd.github+json", 48 | }) 49 | // client time out error 50 | var deadlineExceededError *url.Error 51 | if err != nil { 52 | if errors.As(err, &deadlineExceededError) && deadlineExceededError.Timeout() { 53 | return "", errors.New("request timed out") 54 | } 55 | return "", err 56 | } 57 | 58 | return result.TagName, nil 59 | } 60 | 61 | func (v *version) IsUpdateAvailable(ctx context.Context) (isAvailable bool, version string, err error) { 62 | currentVersion := v.CurrentVersion() 63 | if currentVersion == "under development" { 64 | return false, currentVersion, nil 65 | } 66 | 67 | latestVersion, err := v.LatestVersion(ctx) 68 | if err != nil { 69 | return false, currentVersion, err 70 | } 71 | 72 | current, err := semver.NewVersion(currentVersion) 73 | if err != nil { 74 | return false, currentVersion, err 75 | } 76 | 77 | latest, err := semver.NewVersion(latestVersion) 78 | if err != nil { 79 | return false, currentVersion, err 80 | } 81 | 82 | return latest.GreaterThan(current), latestVersion, nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | const ( 11 | repositoryOwner = "termkit" 12 | repositoryName = "gama" 13 | testCurrentVersion = "1.0.0" 14 | ) 15 | 16 | func NewRepository() Version { 17 | return New(repositoryOwner, repositoryName, testCurrentVersion) 18 | } 19 | 20 | func TestVersion_CurrentVersion(t *testing.T) { 21 | repo := NewRepository() 22 | t.Run("Get current version", func(t *testing.T) { 23 | assert.Equal(t, testCurrentVersion, repo.CurrentVersion()) 24 | }) 25 | } 26 | 27 | func TestVersion_IsUpdateAvailable(t *testing.T) { 28 | ctx := context.Background() 29 | repo := NewRepository() 30 | 31 | t.Run("Check if update is available", func(t *testing.T) { 32 | isAvailable, version, err := repo.IsUpdateAvailable(ctx) 33 | assert.NoError(t, err) 34 | assert.True(t, isAvailable) 35 | assert.NotEmpty(t, version) 36 | }) 37 | } 38 | 39 | func TestRepo_LatestVersion(t *testing.T) { 40 | ctx := context.Background() 41 | repo := NewRepository() 42 | 43 | t.Run("Get latest version", func(t *testing.T) { 44 | res, err := repo.LatestVersion(ctx) 45 | assert.NoError(t, err) 46 | assert.NotEmpty(t, res) 47 | assert.NotEqual(t, testCurrentVersion, res) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/workflow/workflow.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strconv" 7 | 8 | py "github.com/termkit/gama/pkg/yaml" 9 | ) 10 | 11 | type Workflow struct { 12 | // Content is a map of key and value designed for workflow_dispatch.inputs 13 | Content map[string]Content 14 | } 15 | 16 | type Content struct { 17 | Description string 18 | Type string 19 | Required bool 20 | 21 | // KeyValue is a map of key and value designed for JSONContent 22 | KeyValue *[]KeyValue 23 | 24 | // Choice is a map of key and value designed for Options 25 | Choice *Choice 26 | 27 | // Value is a map of string and value designed for string 28 | Value *Value 29 | 30 | // Boolean is a map of string and value designed for boolean 31 | Boolean *Value 32 | } 33 | 34 | type KeyValue struct { 35 | Default string 36 | Key string 37 | Value string 38 | } 39 | 40 | type Value struct { 41 | Default any 42 | Value any 43 | Options []string 44 | } 45 | 46 | type Choice struct { 47 | Default string 48 | Options []string 49 | Value string 50 | } 51 | 52 | func ParseWorkflow(content py.WorkflowContent) (*Workflow, error) { 53 | var w = &Workflow{ 54 | Content: make(map[string]Content), 55 | } 56 | 57 | for key, value := range content.On.WorkflowDispatch.Inputs { 58 | if len(value.JSONContent) > 0 { 59 | var keyValue []KeyValue 60 | for k, v := range value.JSONContent { 61 | keyValue = append(keyValue, KeyValue{ 62 | Key: k, 63 | Value: "", 64 | Default: v, 65 | }) 66 | } 67 | 68 | w.Content[key] = Content{ 69 | Description: value.Description, 70 | Type: "json", 71 | Required: value.Required, 72 | KeyValue: &keyValue, 73 | } 74 | continue // Skip the rest of the loop 75 | } 76 | 77 | switch value.Type { 78 | case "choice": 79 | w.Content[key] = parseChoiceTypes(value) 80 | case "boolean": 81 | w.Content[key] = parseBooleanTypes(value) 82 | case "string", "number", "environment", "": 83 | w.Content[key] = parseInputTypes(value) 84 | } 85 | } 86 | 87 | return w, nil 88 | } 89 | 90 | func (w *Workflow) ToPretty() *Pretty { 91 | var pretty Pretty 92 | var id int 93 | for parent, data := range w.Content { 94 | if data.KeyValue != nil { 95 | for _, v := range *data.KeyValue { 96 | pretty.KeyVals = append(pretty.KeyVals, PrettyKeyValue{ 97 | ID: id, 98 | Parent: stringPtr(parent), 99 | Key: v.Key, 100 | Value: "", 101 | Default: v.Default, 102 | }) 103 | id++ 104 | } 105 | } 106 | if data.Choice != nil { 107 | pretty.Choices = append(pretty.Choices, PrettyChoice{ 108 | ID: id, 109 | Key: parent, 110 | Value: "", 111 | Values: data.Choice.Options, 112 | Default: data.Choice.Default, 113 | }) 114 | id++ 115 | } 116 | if data.Value != nil { 117 | pretty.Inputs = append(pretty.Inputs, PrettyInput{ 118 | ID: id, 119 | Key: parent, 120 | Value: "", 121 | Default: data.Value.Default.(string), 122 | }) 123 | id++ 124 | } 125 | if data.Boolean != nil { 126 | pretty.Boolean = append(pretty.Boolean, PrettyInput{ 127 | ID: id, 128 | Key: parent, 129 | Value: "", 130 | Values: data.Boolean.Options, 131 | Default: data.Boolean.Default.(string), 132 | }) 133 | id++ 134 | } 135 | } 136 | 137 | return &pretty 138 | } 139 | 140 | func (p *Pretty) ToJson() (string, error) { 141 | // Create a map to hold the aggregated data 142 | result := make(map[string]any) 143 | 144 | // Process KeyVals 145 | for _, kv := range p.KeyVals { 146 | if kv.Parent != nil { 147 | parent := *kv.Parent 148 | if _, ok := result[parent]; !ok { 149 | result[parent] = make(map[string]any) 150 | } 151 | result[parent].(map[string]any)[kv.Key] = kv.Value 152 | } else { 153 | result[kv.Key] = kv.Value 154 | } 155 | } 156 | 157 | // Process Choices 158 | for _, c := range p.Choices { 159 | result[c.Key] = c.Value 160 | } 161 | 162 | // Process Inputs 163 | for _, i := range p.Inputs { 164 | result[i.Key] = i.Value 165 | } 166 | 167 | // Process Boolean 168 | for _, b := range p.Boolean { 169 | result[b.Key] = b.Value 170 | } 171 | 172 | if err := convertJsonToString(result); err != nil { 173 | return "", err 174 | } 175 | 176 | modifiedJSON, err := json.Marshal(result) 177 | if err != nil { 178 | return "", err 179 | } 180 | 181 | return string(modifiedJSON), nil 182 | } 183 | 184 | func convertJsonToString(m map[string]any) error { 185 | for k, v := range m { 186 | if reflect.TypeOf(v).Kind() == reflect.Map { 187 | // Convert map to a JSON string 188 | str, err := json.Marshal(v) 189 | if err != nil { 190 | return err 191 | } 192 | m[k] = string(str) 193 | } 194 | } 195 | return nil 196 | } 197 | 198 | type Pretty struct { 199 | Choices []PrettyChoice 200 | Inputs []PrettyInput 201 | Boolean []PrettyInput 202 | KeyVals []PrettyKeyValue 203 | } 204 | 205 | type PrettyChoice struct { 206 | ID int 207 | Key string 208 | Value string 209 | Values []string 210 | Default string 211 | } 212 | 213 | func (c *PrettyChoice) SetValue(value string) { 214 | c.Value = value 215 | } 216 | 217 | type PrettyInput struct { 218 | ID int 219 | Key string 220 | Value string 221 | Values []string 222 | Default string 223 | } 224 | 225 | func (i *PrettyInput) SetValue(value string) { 226 | i.Value = value 227 | } 228 | 229 | type PrettyKeyValue struct { 230 | ID int 231 | Parent *string 232 | Key string 233 | Value string 234 | Default string 235 | } 236 | 237 | func (kv *PrettyKeyValue) SetValue(value string) { 238 | kv.Value = value 239 | } 240 | 241 | func stringPtr(s string) *string { 242 | return &s 243 | } 244 | 245 | func parseChoiceTypes(value py.WorkflowInput) Content { 246 | var defaultValue = "" 247 | if value.Default == nil { 248 | if len(value.Options) != 0 { 249 | defaultValue = value.Options[0] 250 | } 251 | } else { 252 | res, ok := value.Default.(string) 253 | if ok { 254 | defaultValue = res 255 | } 256 | } 257 | 258 | return Content{ 259 | Description: value.Description, 260 | Type: "choice", 261 | Required: value.Required, 262 | Choice: &Choice{ 263 | Default: defaultValue, 264 | Options: value.Options, 265 | Value: "", 266 | }, 267 | } 268 | } 269 | 270 | func parseBooleanTypes(value py.WorkflowInput) Content { 271 | var defaultValue = "false" 272 | 273 | if value.Default != nil { 274 | res, ok := value.Default.(bool) 275 | if ok { 276 | var strBool = strconv.FormatBool(res) 277 | defaultValue = strBool 278 | } 279 | } 280 | 281 | var options = []string{"true", "false"} 282 | 283 | return Content{ 284 | Description: value.Description, 285 | Type: "bool", 286 | Required: value.Required, 287 | Boolean: &Value{ 288 | Default: defaultValue, 289 | Options: options, 290 | Value: "", 291 | }, 292 | } 293 | } 294 | 295 | func parseInputTypes(value py.WorkflowInput) Content { 296 | var defaultValue = "" 297 | 298 | if value.Default != nil { 299 | res, ok := value.Default.(string) 300 | if ok { 301 | defaultValue = res 302 | } 303 | } 304 | 305 | return Content{ 306 | Description: value.Description, 307 | Type: "input", 308 | Required: value.Required, 309 | Value: &Value{ 310 | Default: defaultValue, 311 | Value: "", 312 | }, 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /pkg/workflow/workflow_test.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | py "github.com/termkit/gama/pkg/yaml" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func TestParseWorkflow(t *testing.T) { 12 | var data = []byte(` 13 | name: SystemUpdateTrigger 14 | on: 15 | workflow_dispatch: 16 | inputs: 17 | components: 18 | description: "JSON configuration for component versions" 19 | required: true 20 | default: '{ 21 | "main-engine-ref": "stable", 22 | "ui-layer-ref": "3", 23 | "data-handler-ref": "stable", 24 | "event-logger-ref": "main", 25 | "network-api-ref": "main", 26 | "analytics-service-ref": "main" 27 | }' 28 | deployment_zone: 29 | description: 'Deployment Zone' 30 | type: choice 31 | required: true 32 | options: 33 | - 'alpha' 34 | - 'beta' 35 | - 'gamma' 36 | - 'delta' 37 | - 'epsilon' 38 | - 'zeta' 39 | default: 'trial' 40 | industry_category: 41 | description: 'Industry Category' 42 | type: string 43 | required: true 44 | default: 'general' 45 | boolean_flag: 46 | description: 'Boolean Flag' 47 | type: boolean 48 | required: true 49 | default: true 50 | number: 51 | description: 'Number' 52 | type: number 53 | required: true 54 | default: 1 55 | secrets: inherit 56 | `) 57 | 58 | var workflow py.WorkflowContent 59 | err := yaml.Unmarshal(data, &workflow) 60 | 61 | assert.NoError(t, err) 62 | 63 | w, err := ParseWorkflow(workflow) 64 | assert.NoError(t, err) 65 | 66 | pretty := w.ToPretty() 67 | _ = pretty 68 | 69 | json, err := pretty.ToJson() 70 | _ = json 71 | 72 | t.Log(w) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/yaml/yaml.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | type WorkflowContent struct { 10 | Name string `yaml:"name"` 11 | On struct { 12 | WorkflowDispatch struct { 13 | Inputs map[string]WorkflowInput `yaml:"inputs"` 14 | } `yaml:"workflow_dispatch"` 15 | } `yaml:"on"` 16 | } 17 | 18 | type WorkflowInput struct { 19 | Description string `yaml:"description"` 20 | Required bool `yaml:"required"` 21 | Default any `yaml:"default,omitempty"` 22 | Type string `yaml:"type,omitempty"` 23 | Options []string `yaml:"options,omitempty"` 24 | JSONContent map[string]string `yaml:"-"` // This field is for internal use and won't be filled directly by the YAML unmarshaler 25 | } 26 | 27 | func (i *WorkflowInput) UnmarshalYAML(unmarshal func(any) error) error { 28 | // Define a shadow type to avoid recursion 29 | type shadow WorkflowInput 30 | if err := unmarshal((*shadow)(i)); err != nil { 31 | return err 32 | } 33 | 34 | // Process the default value based on its actual type 35 | switch def := i.Default.(type) { 36 | case string: 37 | // Attempt to unmarshal JSON content if the default value is a string 38 | tempMap := make(map[string]string) 39 | if err := json.Unmarshal([]byte(def), &tempMap); err == nil { 40 | i.JSONContent = tempMap 41 | } 42 | case bool: 43 | // Handle boolean values 44 | i.Default = def 45 | case float64: 46 | // Handle number values (YAML unmarshals numbers to float64 by default) 47 | i.Default = def 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func UnmarshalWorkflowContent(data []byte) (*WorkflowContent, error) { 54 | var workflow WorkflowContent 55 | err := yaml.Unmarshal(data, &workflow) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return &workflow, nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/yaml/yaml_test.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestWorkflowInput_UnmarshalYAML(t *testing.T) { 11 | var data = []byte(` 12 | name: SystemUpdateTrigger 13 | on: 14 | workflow_dispatch: 15 | inputs: 16 | components: 17 | description: "JSON configuration for component versions" 18 | required: true 19 | default: '{ 20 | "main-engine-ref": "stable", 21 | "ui-layer-ref": "3", 22 | "data-handler-ref": "stable", 23 | "event-logger-ref": "main", 24 | "network-api-ref": "main", 25 | "analytics-service-ref": "main" 26 | }' 27 | deployment_zone: 28 | description: 'Deployment Zone' 29 | type: choice 30 | required: true 31 | options: 32 | - 'alpha' 33 | - 'beta' 34 | - 'gamma' 35 | - 'delta' 36 | - 'epsilon' 37 | - 'zeta' 38 | default: 'trial' 39 | industry_category: 40 | description: 'Industry Category' 41 | type: string 42 | required: true 43 | default: 'general' 44 | boolean_flag: 45 | description: 'Boolean Flag' 46 | type: boolean 47 | required: true 48 | default: true 49 | number: 50 | description: 'Number' 51 | type: number 52 | required: true 53 | default: 1 54 | secrets: inherit 55 | `) 56 | 57 | var workflow WorkflowContent 58 | err := yaml.Unmarshal(data, &workflow) 59 | 60 | assert.NoError(t, err) 61 | assert.Equal(t, "SystemUpdateTrigger", workflow.Name) 62 | assert.Equal(t, "JSON configuration for component versions", workflow.On.WorkflowDispatch.Inputs["components"].Description) 63 | assert.Equal(t, true, workflow.On.WorkflowDispatch.Inputs["components"].Required) 64 | assert.Equal(t, "trial", workflow.On.WorkflowDispatch.Inputs["deployment_zone"].Default) 65 | assert.Equal(t, "choice", workflow.On.WorkflowDispatch.Inputs["deployment_zone"].Type) 66 | } 67 | --------------------------------------------------------------------------------