├── .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 |
4 |
5 |
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 | 
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 | [](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 | 
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 | 
18 |
19 | - **Second Permission**: Essential to list triggerable workflows.
20 | 
21 |
22 | - **Third Permission**: Required to read repository contents, enabling workflow triggering.
23 | 
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 |
--------------------------------------------------------------------------------