├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── audit └── audit.go ├── cmd └── cli │ └── main.go ├── create.gif ├── github ├── client.go ├── dispatch.go ├── orgs.go ├── repo.go ├── workflow_runs.go └── workflows.go ├── go.mod ├── go.sum ├── helpers └── http.go ├── log └── log.go ├── orgs ├── org.go └── org_ui.go ├── pipelines ├── approve.go ├── create.go ├── create_test.go ├── delete.go ├── lock.go ├── orchestrator.go ├── pause_resume.go ├── pipeline.go ├── run.go ├── run_test.go ├── run_ui.go ├── runs.go └── show.go ├── pippy.png ├── pippy_flow.png ├── repos ├── repo.go └── repo_ui.go ├── store └── store.go ├── users ├── auth.go └── users.go └── workflows ├── workflow.go └── workflow_ui.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | run-name: CI - ${{inputs.pippy_run_id}} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | pippy_run_id: 8 | type: string 9 | 10 | jobs: 11 | 12 | build: 13 | runs-on: ubuntu-latest 14 | env: 15 | GOPRIVATE: github.com/nixmade 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.24' 23 | 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v6 26 | with: 27 | version: v1.64 28 | 29 | - name: Lint 30 | run: make lint 31 | 32 | - name: Test 33 | run: make test 34 | 35 | - name: App 36 | run: make app 37 | -------------------------------------------------------------------------------- /.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 | 23 | # build 24 | bin/ 25 | dist/ 26 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 1 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | 16 | builds: 17 | - main: ./cmd/cli/main.go 18 | env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - linux 22 | - darwin 23 | 24 | goarch: 25 | - amd64 26 | - arm64 27 | 28 | ldflags: 29 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser 30 | - -extldflags '-static' 31 | 32 | universal_binaries: 33 | - replace: true 34 | 35 | archives: 36 | - format: tar.gz 37 | # this name template makes the OS and Arch compatible with the results of `uname`. 38 | name_template: >- 39 | {{ .ProjectName }}_ 40 | {{- title .Os }}_ 41 | {{- if eq .Arch "amd64" }}x86_64 42 | {{- else if eq .Arch "386" }}i386 43 | {{- else }}{{ .Arch }}{{ end }} 44 | {{- if .Arm }}v{{ .Arm }}{{ end }} 45 | # use zip for windows archives 46 | format_overrides: 47 | - goos: windows 48 | format: zip 49 | 50 | changelog: 51 | sort: asc 52 | filters: 53 | exclude: 54 | - "^docs:" 55 | - "^test:" 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: lint test app 2 | 3 | app: pippy 4 | 5 | # Run tests 6 | test: 7 | go test -timeout 30s ./... -coverprofile cover.out 8 | go tool cover -func=cover.out 9 | # Run go fmt against code 10 | fmt: 11 | go fmt ./... 12 | 13 | # Run go vet against code 14 | vet: 15 | go vet ./... 16 | 17 | lint: fmt vet 18 | golangci-lint run --enable=testifylint 19 | 20 | pippy: 21 | go build -race -ldflags "-extldflags '-static'" -o bin/pippy cmd/cli/main.go 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Create dynamic pipelines on github actions
5 | 6 | 7 | --- 8 | 9 | ## Introduction 10 | 11 | Welcome to pippy, thank you visiting this project. Pippy allows you to create configurable pipelines on Github Actions(a.k.a workflows). Examples, 12 | 13 | 1. `CI(Tests)` **->** `Build docker image` **->** `Deploy docker image (Staging)` **->** `Approval` **->** `Deploy docker image(Production)` 14 | 1. `Terraform plan` **->** `Approval` **->** `Terraform apply` 15 | 1. `Deploy Staging` **->** `E2E Tests(Datadog monitored)` **->** `Approval` **->** `Deploy Production` 16 | 17 | ## Features 18 | 19 | 1. Automatic rollback on workflow or datadog failures. 20 | 1. Halt pipeline on workflow failures. 21 | 1. Datadog Monitoring upto pre configured time (default: 15mins after workflow execution completes). 22 | 1. Stage approval. 23 | 1. Lock pipelines to avoid any approvals. 24 | 1. Audits for critical actions. 25 | 1. Ability to create pipelines dynamically without learning YAML 26 | 27 | ## Installation 28 | 29 | ```bash 30 | brew install nixmade/tap/pippy 31 | ``` 32 | 33 | ## Quick Start 34 | 35 | * Perform github login (all data is stored locally) 36 | 37 | ```bash 38 | pippy user login 39 | ``` 40 | 41 | * Workflows used as part of pipeline needs to be pippy ready. Use spacebar to select repo 42 | 43 | ```bash 44 | pippy workflow validate 45 | ``` 46 | 47 | * After corresponding changes are made to workflows and merged to repo, verify by running above validations 48 | 49 | * Create a new pipeline by following steps 50 | 51 | ```bash 52 | pippy pipeline create --name my-first-pipeline 53 | ``` 54 | 55 |  56 | 57 | * Execute your first pipeline run by providing pipeline inputs 58 | 59 | ```bash 60 | pippy pipeline run execute --name my-first-pipeline -input version=e3d0bea 61 | ``` 62 | 63 | * List recent pipeline runs 64 | 65 | ```bash 66 | pippy pipeline run list --name my-first-pipeline 67 | ``` 68 | 69 | ## How it works 70 | 71 |  72 | 73 | ## Faq 74 | 75 | ### Do you have cloud/hosted solution? 76 | 77 | Cloud/Hosted version is coming soon, please signup here at [pippy](https://pippy.dev), this has some additional features 78 | 79 | * Github triggers 80 | * Collaboration for teams 81 | 82 | ### What are the alternatives? 83 | 84 | If you do not require any features mentioned above, you can easily chain your workflows in github [reusing workflows](https://docs.github.com/en/actions/using-workflows/reusing-workflows), be aware of the [limitations](https://docs.github.com/en/actions/using-workflows/reusing-workflows#limitations). 85 | 86 | ### Where is the data stored? 87 | 88 | All data is stored locally in `HOMEDIR/.pippy` 89 | -------------------------------------------------------------------------------- /audit/audit.go: -------------------------------------------------------------------------------- 1 | package audit 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "reflect" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/nixmade/pippy/store" 14 | 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/charmbracelet/lipgloss/table" 17 | "github.com/google/uuid" 18 | "github.com/urfave/cli/v3" 19 | ) 20 | 21 | const ( 22 | AuditPrefix string = "audit:" 23 | ) 24 | 25 | type Audit struct { 26 | Time time.Time 27 | Resource map[string]string 28 | Actor string 29 | Email string 30 | Message string 31 | } 32 | 33 | func Save(ctx context.Context, name string, resource map[string]string, actor, email, msg string) error { 34 | dbStore, err := store.Get(ctx) 35 | if err != nil { 36 | return err 37 | } 38 | defer store.Close(dbStore) 39 | 40 | key := fmt.Sprintf("%s%s/%s", AuditPrefix, name, uuid.NewString()) 41 | 42 | auditFields := Audit{ 43 | Time: time.Now().UTC(), 44 | Resource: resource, 45 | Actor: actor, 46 | Email: email, 47 | Message: msg, 48 | } 49 | return dbStore.SaveJSON(key, &auditFields) 50 | } 51 | 52 | func Latest(ctx context.Context, name string, resource map[string]string) (*Audit, error) { 53 | dbStore, err := store.Get(ctx) 54 | if err != nil { 55 | return nil, err 56 | } 57 | defer store.Close(dbStore) 58 | 59 | auditKeyPrefix := fmt.Sprintf("%s%s/", AuditPrefix, name) 60 | var latestAudit Audit 61 | auditItr := func(key any, value any) error { 62 | var data Audit 63 | if err := json.Unmarshal([]byte(value.(string)), &data); err != nil { 64 | return err 65 | } 66 | if !reflect.DeepEqual(data.Resource, resource) { 67 | return nil 68 | } 69 | if data.Time.Compare(latestAudit.Time) >= 0 { 70 | latestAudit = data 71 | } 72 | return nil 73 | } 74 | err = dbStore.SortedDescN(auditKeyPrefix, "$.Time", -1, auditItr) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | return &latestAudit, nil 80 | } 81 | 82 | func ListAudits(ctx context.Context) (map[string]Audit, error) { 83 | return ListAuditsN(ctx, -1) 84 | } 85 | 86 | func ListAuditsN(ctx context.Context, limit int64) (map[string]Audit, error) { 87 | dbStore, err := store.Get(ctx) 88 | if err != nil { 89 | return nil, err 90 | } 91 | defer store.Close(dbStore) 92 | 93 | audits := make(map[string]Audit) 94 | auditItr := func(key any, value any) error { 95 | var data Audit 96 | if err := json.Unmarshal([]byte(value.(string)), &data); err != nil { 97 | return err 98 | } 99 | audits[key.(string)] = data 100 | return nil 101 | } 102 | err = dbStore.SortedDescN(AuditPrefix, "$.Time", limit, auditItr) 103 | if err != nil { 104 | return nil, err 105 | } 106 | return audits, nil 107 | } 108 | 109 | func ListAuditsUI(limit int64) error { 110 | audits, err := ListAuditsN(context.Background(), limit) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | rows := [][]string{} 116 | for auditKey, data := range audits { 117 | strippedAuditKey, _ := strings.CutPrefix(auditKey, AuditPrefix) 118 | 119 | auditNameId := strings.Split(strippedAuditKey, "/") 120 | rows = append(rows, []string{data.Time.Format(time.RFC3339), auditNameId[1], auditNameId[0], convertResouceToList(data.Resource), data.Actor, data.Email, data.Message}) 121 | 122 | } 123 | sort.Slice(rows, func(i, j int) bool { 124 | leftTime, err := time.Parse(time.RFC3339, rows[i][0]) 125 | if err != nil { 126 | return false 127 | } 128 | 129 | rightTime, err := time.Parse(time.RFC3339, rows[j][0]) 130 | if err != nil { 131 | return false 132 | } 133 | 134 | return leftTime.After(rightTime) 135 | }) 136 | 137 | re := lipgloss.NewRenderer(os.Stdout) 138 | 139 | var ( 140 | // HeaderStyle is the lipgloss style used for the table headers. 141 | HeaderStyle = re.NewStyle().Foreground(lipgloss.Color("#929292")).Bold(true).Align(lipgloss.Center) 142 | // CellStyle is the base lipgloss style used for the table rows. 143 | CellStyle = re.NewStyle().Padding(0, 1).Width(14) 144 | // OddRowStyle is the lipgloss style used for odd-numbered table rows. 145 | OddRowStyle = CellStyle.Foreground(lipgloss.Color("#FDFF90")) 146 | // EvenRowStyle is the lipgloss style used for even-numbered table rows. 147 | EvenRowStyle = CellStyle.Foreground(lipgloss.Color("#97AD64")) 148 | // BorderStyle is the lipgloss style used for the table border. 149 | BorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#97AD64")) 150 | ) 151 | 152 | t := table.New(). 153 | Width(120). 154 | Border(lipgloss.RoundedBorder()). 155 | BorderStyle(BorderStyle). 156 | Headers("TIME", "ID", "TYPE", "RESOURCE", "ACTOR", "EMAIL", "MESSAGE"). 157 | Rows(rows...). 158 | StyleFunc(func(row, col int) lipgloss.Style { 159 | switch { 160 | case row == 0: 161 | return HeaderStyle 162 | case row%2 == 0: 163 | return EvenRowStyle 164 | default: 165 | return OddRowStyle 166 | } 167 | }) 168 | 169 | fmt.Println(t) 170 | 171 | return nil 172 | } 173 | 174 | func convertResouceToList(resource map[string]string) string { 175 | var keyValues []string 176 | for key, value := range resource { 177 | keyValues = append(keyValues, fmt.Sprintf("%s=%s", key, value)) 178 | } 179 | 180 | return strings.Join(keyValues, ",") 181 | } 182 | 183 | func Command() *cli.Command { 184 | return &cli.Command{ 185 | Name: "audit", 186 | Usage: "audit management", 187 | Commands: []*cli.Command{ 188 | { 189 | Name: "list", 190 | Usage: "list", 191 | Action: func(ctx context.Context, c *cli.Command) error { 192 | if err := ListAuditsUI(c.Int64("limit")); err != nil { 193 | fmt.Printf("%v\n", err) 194 | return err 195 | } 196 | return nil 197 | }, 198 | Flags: []cli.Flag{ 199 | &cli.Int64Flag{ 200 | Name: "limit", 201 | Usage: "audit list limit", 202 | Value: int64(10), 203 | Required: false, 204 | }, 205 | }, 206 | }, 207 | }, 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /cmd/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "sort" 9 | 10 | "github.com/nixmade/pippy/audit" 11 | "github.com/nixmade/pippy/orgs" 12 | "github.com/nixmade/pippy/pipelines" 13 | "github.com/nixmade/pippy/repos" 14 | "github.com/nixmade/pippy/users" 15 | "github.com/nixmade/pippy/workflows" 16 | 17 | "github.com/urfave/cli/v3" 18 | ) 19 | 20 | var ( 21 | version = "1.0.0-beta" 22 | // commit = "none" 23 | // date = "unknown" 24 | ) 25 | 26 | func main() { 27 | cli.VersionFlag = &cli.BoolFlag{ 28 | Name: "print-version", 29 | Aliases: []string{"V"}, 30 | Usage: "print only the version", 31 | } 32 | appCli := &cli.Command{ 33 | Name: "pippy", 34 | Version: fmt.Sprintf("v%s", version), 35 | Usage: "pippy interacts with github actions", 36 | Commands: []*cli.Command{ 37 | users.Command(), 38 | workflows.Command(), 39 | repos.Command(), 40 | orgs.Command(), 41 | pipelines.Command(), 42 | audit.Command(), 43 | }, 44 | } 45 | 46 | sort.Sort(cli.FlagsByName(appCli.Flags)) 47 | 48 | if err := appCli.Run(context.Background(), os.Args); err != nil { 49 | log.Fatal(err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /create.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nixmade/pippy/ca1c6377a20b57582b61675595b84ca1af549dd2/create.gif -------------------------------------------------------------------------------- /github/client.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/bradleyfalzon/ghinstallation/v2" 10 | "github.com/google/go-github/v71/github" 11 | "github.com/nixmade/pippy/users" 12 | ) 13 | 14 | type contextKey struct { 15 | name string 16 | } 17 | 18 | func (k *contextKey) String() string { 19 | return "context value " + k.name 20 | } 21 | 22 | var ( 23 | AccessTokenCtx = &contextKey{"AccessToken"} 24 | PrivateKeyCtx = &contextKey{"PrivateKey"} 25 | AppIDCtx = &contextKey{"AppID"} 26 | InstallationIDCtx = &contextKey{"InstallationID"} 27 | ) 28 | 29 | type Client interface { 30 | ListRepos(repoType string) ([]Repo, error) 31 | GetWorkflow(org, repo string, id int64) (*Workflow, error) 32 | ListWorkflows(org, repo string) ([]Workflow, error) 33 | ListWorkflowRuns(org, repo string, workflowID int64, created string) ([]WorkflowRun, error) 34 | CreateWorkflowDispatch(org, repo string, workflowID int64, ref string, inputs map[string]interface{}) error 35 | ValidateWorkflow(org, repo, path string) ([]string, map[string]string, error) 36 | ValidateWorkflowFull(org, repo, path string) (string, string, error) 37 | ListOrgsForUser() ([]Org, error) 38 | } 39 | 40 | type Github struct { 41 | context.Context 42 | } 43 | 44 | func getAccessToken(ctx context.Context) (accessToken string, err error) { 45 | accessTokenCtx := ctx.Value(AccessTokenCtx) 46 | if accessTokenCtx != nil { 47 | return accessTokenCtx.(string), nil 48 | } 49 | 50 | return users.GetCachedAccessToken() 51 | } 52 | 53 | func (g *Github) New() (*github.Client, error) { 54 | privateKeyCtx := g.Context.Value(PrivateKeyCtx) 55 | if privateKeyCtx != nil { 56 | privateKey, err := base64.StdEncoding.DecodeString(privateKeyCtx.(string)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | appIDCtx := g.Context.Value(AppIDCtx) 61 | if appIDCtx == nil { 62 | return nil, fmt.Errorf("app id is not provided or empty") 63 | } 64 | installationIDCtx := g.Context.Value(InstallationIDCtx) 65 | if installationIDCtx == nil { 66 | return nil, fmt.Errorf("installation id is not provided or empty") 67 | } 68 | tr, err := ghinstallation.New(http.DefaultTransport, appIDCtx.(int64), installationIDCtx.(int64), privateKey) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return github.NewClient(&http.Client{Transport: tr}), nil 73 | } 74 | 75 | accessToken, err := getAccessToken(g.Context) 76 | if err != nil { 77 | return nil, err 78 | } 79 | return github.NewClient(nil).WithAuthToken(accessToken), nil 80 | } 81 | 82 | var ( 83 | DefaultClient Client = &Github{Context: context.Background()} 84 | ) 85 | -------------------------------------------------------------------------------- /github/dispatch.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/go-github/v71/github" 8 | ) 9 | 10 | func (g *Github) CreateWorkflowDispatch(org, repo string, workflowID int64, ref string, inputs map[string]interface{}) error { 11 | client, err := g.New() 12 | if err != nil { 13 | return err 14 | } 15 | 16 | event := github.CreateWorkflowDispatchEventRequest{ 17 | Ref: ref, 18 | Inputs: inputs, 19 | } 20 | resp, err := client.Actions.CreateWorkflowDispatchEventByID(context.Background(), org, repo, workflowID, event) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { 26 | return nil 27 | } 28 | 29 | return fmt.Errorf("workflow create run dispatch returned error %s", resp.Status) 30 | } 31 | -------------------------------------------------------------------------------- /github/orgs.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/v71/github" 7 | ) 8 | 9 | type Org struct { 10 | Name, Login, Url, Company, AvatarURL string 11 | Id int64 12 | } 13 | 14 | func (i Org) Title() string { return i.Login } 15 | func (i Org) Description() string { return i.AvatarURL } 16 | func (i Org) FilterValue() string { return i.Login } 17 | 18 | func (g *Github) ListOrgsForUser() ([]Org, error) { 19 | client, err := g.New() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | opt := &github.ListOptions{} 25 | orgs, _, err := client.Organizations.List(context.Background(), "", opt) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | var orgItems []Org 31 | 32 | for i := 0; i < len(orgs); i++ { 33 | orgItems = append(orgItems, Org{ 34 | Name: orgs[i].GetName(), 35 | Id: orgs[i].GetID(), 36 | Login: orgs[i].GetLogin(), 37 | Url: orgs[i].GetHTMLURL(), 38 | Company: orgs[i].GetCompany(), 39 | AvatarURL: orgs[i].GetAvatarURL(), 40 | }) 41 | } 42 | 43 | return orgItems, nil 44 | } 45 | -------------------------------------------------------------------------------- /github/repo.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/v71/github" 7 | ) 8 | 9 | type Repo struct { 10 | Name, Url, Detail string 11 | } 12 | 13 | func (i Repo) Title() string { return i.Name } 14 | func (i Repo) Description() string { return i.Detail } 15 | func (i Repo) FilterValue() string { return i.Name } 16 | 17 | func (g *Github) ListRepos(repoType string) ([]Repo, error) { 18 | client, err := g.New() 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | opt := &github.RepositoryListByAuthenticatedUserOptions{Type: repoType} 24 | repos, _, err := client.Repositories.ListByAuthenticatedUser(context.Background(), opt) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | var repoItems []Repo 30 | 31 | for i := 0; i < len(repos); i++ { 32 | repoItems = append(repoItems, Repo{ 33 | Name: repos[i].GetFullName(), 34 | Url: repos[i].GetHTMLURL(), 35 | Detail: repos[i].GetDescription(), 36 | }) 37 | } 38 | 39 | return repoItems, nil 40 | } 41 | -------------------------------------------------------------------------------- /github/workflow_runs.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/go-github/v71/github" 8 | ) 9 | 10 | type WorkflowRun struct { 11 | Name, Status, Url, Conclusion string 12 | Id, WorkflowID int64 13 | RunStartedAt, UpdatedAt time.Time 14 | } 15 | 16 | func (i WorkflowRun) Title() string { return i.Name } 17 | func (i WorkflowRun) Description() string { return i.Url } 18 | func (i WorkflowRun) FilterValue() string { return i.Name } 19 | 20 | func (g *Github) ListWorkflowRuns(org, repo string, workflowID int64, created string) ([]WorkflowRun, error) { 21 | client, err := g.New() 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | opt := &github.ListWorkflowRunsOptions{ 27 | Event: "workflow_dispatch", 28 | Created: created, 29 | } 30 | workflowRuns, _, err := client.Actions.ListWorkflowRunsByID(context.Background(), org, repo, workflowID, opt) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | var workflowItems []WorkflowRun 36 | 37 | for i := 0; i < len(workflowRuns.WorkflowRuns); i++ { 38 | workflowItems = append(workflowItems, WorkflowRun{ 39 | Name: workflowRuns.WorkflowRuns[i].GetDisplayTitle(), 40 | Url: workflowRuns.WorkflowRuns[i].GetHTMLURL(), 41 | Id: workflowRuns.WorkflowRuns[i].GetID(), 42 | Status: workflowRuns.WorkflowRuns[i].GetStatus(), 43 | WorkflowID: workflowRuns.WorkflowRuns[i].GetWorkflowID(), 44 | Conclusion: workflowRuns.WorkflowRuns[i].GetConclusion(), 45 | RunStartedAt: workflowRuns.WorkflowRuns[i].GetRunStartedAt().Time, 46 | UpdatedAt: workflowRuns.WorkflowRuns[i].GetUpdatedAt().Time, 47 | }) 48 | } 49 | 50 | return workflowItems, nil 51 | } 52 | -------------------------------------------------------------------------------- /github/workflows.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "gopkg.in/yaml.v3" 9 | 10 | "github.com/google/go-github/v71/github" 11 | ) 12 | 13 | type Workflow struct { 14 | Name, Url, State, Path string 15 | Id int64 16 | } 17 | 18 | func (i Workflow) Title() string { return i.Name } 19 | func (i Workflow) Description() string { return i.Url } 20 | func (i Workflow) FilterValue() string { return i.Name } 21 | 22 | func (g *Github) GetWorkflow(org, repo string, id int64) (*Workflow, error) { 23 | client, err := g.New() 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | workflow, _, err := client.Actions.GetWorkflowByID(context.Background(), org, repo, id) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return &Workflow{ 34 | Name: workflow.GetName(), 35 | Url: workflow.GetHTMLURL(), 36 | Id: workflow.GetID(), 37 | State: workflow.GetState(), 38 | Path: workflow.GetPath(), 39 | }, nil 40 | } 41 | 42 | func (g *Github) ListWorkflows(org, repo string) ([]Workflow, error) { 43 | client, err := g.New() 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | opt := &github.ListOptions{} 49 | workflows, _, err := client.Actions.ListWorkflows(context.Background(), org, repo, opt) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | var workflowItems []Workflow 55 | 56 | for i := 0; i < len(workflows.Workflows); i++ { 57 | workflowItems = append(workflowItems, Workflow{ 58 | Name: workflows.Workflows[i].GetName(), 59 | Url: workflows.Workflows[i].GetHTMLURL(), 60 | Id: workflows.Workflows[i].GetID(), 61 | State: workflows.Workflows[i].GetState(), 62 | Path: workflows.Workflows[i].GetPath(), 63 | }) 64 | } 65 | 66 | return workflowItems, nil 67 | } 68 | 69 | func (g *Github) ValidateWorkflow(org, repo, path string) ([]string, map[string]string, error) { 70 | client, err := g.New() 71 | if err != nil { 72 | return nil, nil, err 73 | } 74 | 75 | opt := &github.RepositoryContentGetOptions{} 76 | fileContent, _, _, err := client.Repositories.GetContents(context.Background(), org, repo, path, opt) 77 | if err != nil { 78 | return nil, nil, err 79 | } 80 | 81 | content, err := fileContent.GetContent() 82 | if err != nil { 83 | return nil, nil, err 84 | } 85 | 86 | workflowsDef := make(map[string]interface{}) 87 | err = yaml.Unmarshal([]byte(content), workflowsDef) 88 | if err != nil { 89 | return nil, nil, err 90 | } 91 | 92 | var workflowName string 93 | var workflowRunName string 94 | //workflowDispatchInputs := make(map[string]string) 95 | 96 | if value, ok := workflowsDef["name"]; ok { 97 | workflowName = value.(string) 98 | } 99 | 100 | if value, ok := workflowsDef["run-name"]; ok { 101 | workflowRunName = value.(string) 102 | } 103 | 104 | var requiredChanges []string 105 | workflowInputs := make(map[string]string) 106 | 107 | if onDispatches, ok := workflowsDef["on"]; ok { 108 | if dispatches, ok := onDispatches.(map[string]interface{}); ok { 109 | if workflowDispatch, ok := dispatches["workflow_dispatch"]; ok { 110 | if workflowDispatchInputs, ok := workflowDispatch.(map[string]interface{}); ok { 111 | if inputs, ok := workflowDispatchInputs["inputs"]; ok { 112 | newInputs := make(map[string]interface{}) 113 | for key, value := range inputs.(map[string]interface{}) { 114 | inputType := value.(map[string]interface{})["type"] 115 | newInputs[key] = map[string]interface{}{"type": inputType.(string)} 116 | workflowInputs[key] = inputType.(string) 117 | } 118 | if _, ok := newInputs["pippy_run_id"]; !ok { 119 | newInputs["pippy_run_id"] = map[string]string{"type": "string"} 120 | 121 | change, err := yaml.Marshal(map[string]interface{}{"workflow_dispatch": map[string]interface{}{"inputs": newInputs}}) 122 | if err != nil { 123 | return nil, nil, err 124 | } 125 | requiredChanges = append(requiredChanges, string(change)) 126 | } 127 | } 128 | } else { 129 | change, err := yaml.Marshal(map[string]interface{}{"workflow_dispatch": map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}}}) 130 | if err != nil { 131 | return nil, nil, err 132 | } 133 | requiredChanges = append(requiredChanges, string(change)) 134 | } 135 | } else { 136 | newDispatch := map[string]interface{}{"workflow_dispatch": map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}}} 137 | changes, err := yaml.Marshal(map[string]interface{}{"on": newDispatch}) 138 | if err != nil { 139 | return nil, nil, err 140 | } 141 | requiredChanges = append(requiredChanges, string(changes)) 142 | } 143 | } else { 144 | // this is an array 145 | dispatches := onDispatches.([]interface{}) 146 | newDispatch := make(map[string]interface{}) 147 | for _, dispatch := range dispatches { 148 | newDispatch[dispatch.(string)] = map[string]interface{}{} 149 | } 150 | newDispatch["workflow_dispatch"] = map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}} 151 | change, err := yaml.Marshal(map[string]interface{}{"on": newDispatch}) 152 | if err != nil { 153 | return nil, nil, err 154 | } 155 | requiredChanges = append(requiredChanges, string(change)) 156 | } 157 | } else { 158 | newDispatch := map[string]interface{}{"workflow_dispatch": map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}}} 159 | changes, err := yaml.Marshal(map[string]interface{}{"on": newDispatch}) 160 | if err != nil { 161 | return nil, nil, err 162 | } 163 | requiredChanges = append(requiredChanges, string(changes)) 164 | } 165 | 166 | if !strings.Contains(workflowRunName, "inputs.pippy_run_id") { 167 | if workflowRunName == "" { 168 | workflowRunName = workflowName 169 | } 170 | change, err := yaml.Marshal(map[string]interface{}{"run-name": fmt.Sprintf("%s - ${{inputs.pippy_run_id}}", workflowRunName)}) 171 | if err != nil { 172 | return nil, nil, err 173 | } 174 | requiredChanges = append(requiredChanges, string(change)) 175 | } 176 | 177 | return requiredChanges, workflowInputs, nil 178 | } 179 | 180 | func (g *Github) ValidateWorkflowFull(org, repo, path string) (string, string, error) { 181 | client, err := g.New() 182 | if err != nil { 183 | return "", "", err 184 | } 185 | 186 | opt := &github.RepositoryContentGetOptions{} 187 | fileContent, _, _, err := client.Repositories.GetContents(context.Background(), org, repo, path, opt) 188 | if err != nil { 189 | return "", "", err 190 | } 191 | 192 | oldFile, err := fileContent.GetContent() 193 | if err != nil { 194 | return "", "", err 195 | } 196 | 197 | workflowsDef := make(map[string]interface{}) 198 | err = yaml.Unmarshal([]byte(oldFile), workflowsDef) 199 | if err != nil { 200 | return oldFile, "", err 201 | } 202 | 203 | var workflowName string 204 | var workflowRunName string 205 | //workflowDispatchInputs := make(map[string]string) 206 | 207 | if value, ok := workflowsDef["name"]; ok { 208 | workflowName = value.(string) 209 | } 210 | 211 | if value, ok := workflowsDef["run-name"]; ok { 212 | workflowRunName = value.(string) 213 | } 214 | 215 | workflowInputs := make(map[string]string) 216 | if onDispatches, ok := workflowsDef["on"]; ok { 217 | if dispatches, ok := onDispatches.(map[string]interface{}); ok { 218 | if workflowDispatch, ok := dispatches["workflow_dispatch"]; ok { 219 | if workflowDispatchInputs, ok := workflowDispatch.(map[string]interface{}); ok { 220 | if inputs, ok := workflowDispatchInputs["inputs"]; ok { 221 | newInputs := make(map[string]interface{}) 222 | for key, value := range inputs.(map[string]interface{}) { 223 | inputType := value.(map[string]interface{})["type"] 224 | newInputs[key] = map[string]interface{}{"type": inputType.(string)} 225 | workflowInputs[key] = inputType.(string) 226 | } 227 | if _, ok := newInputs["pippy_run_id"]; !ok { 228 | newInputs["pippy_run_id"] = map[string]string{"type": "string"} 229 | workflowDispatchInputs["inputs"] = map[string]interface{}{"inputs": newInputs} 230 | } 231 | } 232 | } else { 233 | dispatches["workflow_dispatch"] = map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}} 234 | } 235 | } else { 236 | dispatches["workflow_dispatch"] = map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}} 237 | } 238 | } else { 239 | // this is an array 240 | dispatches := onDispatches.([]interface{}) 241 | newDispatch := make(map[string]interface{}) 242 | for _, dispatch := range dispatches { 243 | newDispatch[dispatch.(string)] = map[string]interface{}{} 244 | } 245 | newDispatch["workflow_dispatch"] = map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}} 246 | workflowsDef["on"] = newDispatch 247 | } 248 | } else { 249 | newDispatch := map[string]interface{}{"workflow_dispatch": map[string]interface{}{"inputs": map[string]interface{}{"pippy_run_id": map[string]interface{}{"type": "string"}}}} 250 | workflowsDef["on"] = newDispatch 251 | } 252 | 253 | if !strings.Contains(workflowRunName, "inputs.pippy_run_id") { 254 | if workflowRunName == "" { 255 | workflowRunName = workflowName 256 | } 257 | workflowsDef["run-name"] = fmt.Sprintf("%s - ${{inputs.pippy_run_id}}", workflowRunName) 258 | } 259 | 260 | newFile, err := yaml.Marshal(workflowsDef) 261 | if err != nil { 262 | return oldFile, "", err 263 | } 264 | 265 | return oldFile, string(newFile), nil 266 | } 267 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nixmade/pippy 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/bradleyfalzon/ghinstallation/v2 v2.15.0 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.5 9 | github.com/charmbracelet/huh v0.7.0 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/google/go-github/v71 v71.0.0 12 | github.com/google/uuid v1.6.0 13 | github.com/nixmade/orchestrator v1.0.7 14 | github.com/rs/zerolog v1.34.0 15 | github.com/stretchr/testify v1.10.0 16 | github.com/urfave/cli/v3 v3.3.3 17 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | github.com/ajg/form v1.5.1 // indirect 23 | github.com/atotto/clipboard v0.1.4 // indirect 24 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 25 | github.com/catppuccin/go v0.3.0 // indirect 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 28 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 29 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 30 | github.com/charmbracelet/x/exp/strings v0.0.0-20250512155337-1597d732b701 // indirect 31 | github.com/charmbracelet/x/term v0.2.1 // indirect 32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 33 | github.com/dgraph-io/badger/v4 v4.7.0 // indirect 34 | github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect 35 | github.com/dustin/go-humanize v1.0.1 // indirect 36 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 37 | github.com/go-chi/chi/v5 v5.2.1 // indirect 38 | github.com/go-chi/render v1.0.3 // indirect 39 | github.com/go-logr/logr v1.4.2 // indirect 40 | github.com/go-logr/stdr v1.2.2 // indirect 41 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 42 | github.com/google/flatbuffers v25.2.10+incompatible // indirect 43 | github.com/google/go-querystring v1.1.0 // indirect 44 | github.com/jackc/pgpassfile v1.0.0 // indirect 45 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 46 | github.com/jackc/pgx/v5 v5.7.4 // indirect 47 | github.com/klauspost/compress v1.18.0 // indirect 48 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 49 | github.com/mattn/go-colorable v0.1.14 // indirect 50 | github.com/mattn/go-isatty v0.0.20 // indirect 51 | github.com/mattn/go-localereader v0.0.1 // indirect 52 | github.com/mattn/go-runewidth v0.0.16 // indirect 53 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 54 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 55 | github.com/muesli/cancelreader v0.2.2 // indirect 56 | github.com/muesli/termenv v0.16.0 // indirect 57 | github.com/ohler55/ojg v1.26.5 // indirect 58 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 59 | github.com/rivo/uniseg v0.4.7 // indirect 60 | github.com/sahilm/fuzzy v0.1.1 // indirect 61 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 62 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 63 | go.opentelemetry.io/otel v1.35.0 // indirect 64 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 65 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 66 | golang.org/x/crypto v0.38.0 // indirect 67 | golang.org/x/net v0.40.0 // indirect 68 | golang.org/x/sync v0.14.0 // indirect 69 | golang.org/x/sys v0.33.0 // indirect 70 | golang.org/x/text v0.25.0 // indirect 71 | google.golang.org/protobuf v1.36.6 // indirect 72 | ) 73 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 4 | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 10 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 11 | github.com/bradleyfalzon/ghinstallation/v2 v2.15.0 h1:7r2rPUM04rgszMP0U1UZ1M5VoVVIlsaBSnpABfYxcQY= 12 | github.com/bradleyfalzon/ghinstallation/v2 v2.15.0/go.mod h1:PoH9Vhy82OeRFZfxsVrk3mfQhVkEzou9OOwPOsEhiXE= 13 | github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 14 | github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 15 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 16 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 17 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 18 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 19 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 20 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 21 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 22 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 23 | github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= 24 | github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= 25 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 26 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 27 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= 28 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 29 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 30 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 31 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 32 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 33 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 34 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 35 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 36 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 37 | github.com/charmbracelet/x/exp/strings v0.0.0-20250512155337-1597d732b701 h1:O4k9kHFwC+pOyzMRNiP5R+y8XMx6Mb//gS8BkMdMdS8= 38 | github.com/charmbracelet/x/exp/strings v0.0.0-20250512155337-1597d732b701/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k= 39 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 40 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 41 | github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 42 | github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 43 | github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 44 | github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 45 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 46 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 47 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 48 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 49 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 50 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 51 | github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y= 52 | github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA= 53 | github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= 54 | github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= 55 | github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= 56 | github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 57 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 58 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 59 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 60 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 61 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 62 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 63 | github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= 64 | github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 65 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 66 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 67 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 68 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 69 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 70 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 71 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 72 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 73 | github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= 74 | github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 75 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 76 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 77 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 78 | github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= 79 | github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= 80 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 81 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 82 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 83 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 84 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 85 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 86 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 87 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 88 | github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= 89 | github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 90 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 91 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 92 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 93 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 94 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 95 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 96 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 97 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 98 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 99 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 100 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 101 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 102 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 103 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 104 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 105 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 106 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 107 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 108 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 109 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 110 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 111 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 112 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 113 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 114 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 115 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 116 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 117 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 118 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 119 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 120 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 121 | github.com/nixmade/orchestrator v1.0.7 h1:NXoun1IDvRuxv7FZDK9vapPdIPL3p6PHipiYOvNp3UQ= 122 | github.com/nixmade/orchestrator v1.0.7/go.mod h1:t6UAZKV7j8ojmzwgjNFZP+Tz/a5dYZsB/Khw5XRpp/Q= 123 | github.com/ohler55/ojg v1.26.5 h1:P8BCQBjPjteL2rJhSwq2OheuO2Qugevf8lROp/PSI1M= 124 | github.com/ohler55/ojg v1.26.5/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= 125 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 126 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 127 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 128 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 129 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 130 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 131 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 132 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 133 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 134 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 135 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 136 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 137 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 138 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 139 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 140 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 141 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 142 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 143 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 144 | github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 145 | github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 146 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 147 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 148 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 149 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 150 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 151 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 152 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 153 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 154 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 155 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 156 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 157 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 158 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 159 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 160 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 161 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 162 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 163 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 164 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 165 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 166 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 167 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 168 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 169 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 170 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 171 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 172 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 173 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 174 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 175 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 176 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 177 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 178 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 179 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 180 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 181 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 182 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 183 | -------------------------------------------------------------------------------- /helpers/http.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func HttpPost(url, post string) (string, error) { 11 | req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(post))) 12 | if err != nil { 13 | return "", err 14 | } 15 | req.Close = true 16 | respU, err := http.DefaultClient.Do(req) 17 | if err != nil { 18 | return "", err 19 | } 20 | defer respU.Body.Close() 21 | 22 | if respU.StatusCode != http.StatusOK { 23 | return "", fmt.Errorf("get returned %d, expected success with 200, error: %s", respU.StatusCode, respU.Status) 24 | } 25 | 26 | body, err := io.ReadAll(respU.Body) 27 | if err != nil { 28 | return "", err 29 | } 30 | return string(body), err 31 | } 32 | 33 | func HttpGet(url string, headers map[string]string) (string, error) { 34 | req, err := http.NewRequest("GET", url, nil) 35 | if err != nil { 36 | return "", err 37 | } 38 | for key, value := range headers { 39 | req.Header.Add(key, value) 40 | } 41 | req.Close = true 42 | respU, err := http.DefaultClient.Do(req) 43 | if err != nil { 44 | return "", err 45 | } 46 | defer respU.Body.Close() 47 | 48 | if respU.StatusCode != http.StatusOK { 49 | return "", fmt.Errorf("get returned %d, expected success with 200, error: %s", respU.StatusCode, respU.Status) 50 | } 51 | 52 | body, err := io.ReadAll(respU.Body) 53 | if err != nil { 54 | return "", err 55 | } 56 | return string(body), err 57 | } 58 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "github.com/rs/zerolog" 8 | "gopkg.in/natefinch/lumberjack.v2" 9 | ) 10 | 11 | var ( 12 | DefaultLogger *zerolog.Logger = nil 13 | ) 14 | 15 | func Get() *zerolog.Logger { 16 | if DefaultLogger != nil { 17 | return DefaultLogger 18 | } 19 | 20 | homedir, err := os.UserHomeDir() 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | newLogger := zerolog.New(&lumberjack.Logger{ 26 | Filename: path.Join(homedir, ".pippy", "logs", "pippy.log"), 27 | MaxBackups: 3, // files 28 | MaxSize: 10, // megabytes 29 | MaxAge: 7, // days 30 | }).With(). 31 | Caller(). 32 | Timestamp(). 33 | Logger(). 34 | Level(zerolog.DebugLevel) 35 | 36 | DefaultLogger = &newLogger 37 | 38 | return DefaultLogger 39 | } 40 | -------------------------------------------------------------------------------- /orgs/org.go: -------------------------------------------------------------------------------- 1 | package orgs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/nixmade/pippy/github" 8 | 9 | "github.com/urfave/cli/v3" 10 | ) 11 | 12 | func Command() *cli.Command { 13 | return &cli.Command{ 14 | Name: "org", 15 | Usage: "org management", 16 | Commands: []*cli.Command{ 17 | { 18 | Name: "list", 19 | Usage: "list orgs for authenticated user", 20 | Action: func(ctx context.Context, c *cli.Command) error { 21 | if err := RunListOrgs(github.DefaultClient); err != nil { 22 | fmt.Printf("%v\n", err) 23 | return err 24 | } 25 | return nil 26 | }, 27 | }, 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /orgs/org_ui.go: -------------------------------------------------------------------------------- 1 | package orgs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/nixmade/pippy/github" 10 | ) 11 | 12 | var ( 13 | docStyle = lipgloss.NewStyle().Margin(1, 2) 14 | ) 15 | 16 | type model struct { 17 | list list.Model 18 | } 19 | 20 | func (m model) Init() tea.Cmd { 21 | return nil 22 | } 23 | 24 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 25 | switch msg := msg.(type) { 26 | case tea.KeyMsg: 27 | if msg.String() == "ctrl+c" { 28 | return m, tea.Quit 29 | } 30 | case tea.WindowSizeMsg: 31 | h, v := docStyle.GetFrameSize() 32 | m.list.SetSize(msg.Width-h, msg.Height-v) 33 | } 34 | 35 | var cmd tea.Cmd 36 | m.list, cmd = m.list.Update(msg) 37 | return m, cmd 38 | } 39 | 40 | func (m model) View() string { 41 | return docStyle.Render(m.list.View()) 42 | } 43 | 44 | func Run(items []list.Item) error { 45 | m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)} 46 | m.list.Title = "Orgs" 47 | 48 | p := tea.NewProgram(m, tea.WithAltScreen()) 49 | 50 | if _, err := p.Run(); err != nil { 51 | fmt.Println("Error running program:", err) 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func RunListOrgs(c github.Client) error { 59 | orgItems, err := c.ListOrgsForUser() 60 | if err != nil { 61 | return err 62 | } 63 | var listItems []list.Item 64 | for _, orgItem := range orgItems { 65 | listItems = append(listItems, orgItem) 66 | } 67 | return Run(listItems) 68 | } 69 | -------------------------------------------------------------------------------- /pipelines/approve.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/nixmade/pippy/audit" 10 | "github.com/nixmade/pippy/users" 11 | 12 | "github.com/charmbracelet/huh" 13 | ) 14 | 15 | const ( 16 | AUDIT_APPROVED string = "Approved" 17 | AUDIT_CANCEL_APPROVAL string = "CancelApproval" 18 | ) 19 | 20 | func approvePipelineRun(name, id string, pipeline *Pipeline, pipelineRun *PipelineRun) error { 21 | s := "" 22 | 23 | type pendingApproval struct { 24 | i int 25 | name string 26 | state string 27 | } 28 | 29 | var approvalsRequired []pendingApproval 30 | 31 | for i, stage := range pipeline.Stages { 32 | if !stage.Approval { 33 | continue 34 | } 35 | 36 | stageRun := pipelineRun.Stages[i] 37 | 38 | approval := stageRun.Metadata.Approval 39 | if approval.Name != "" || approval.Login != "" { 40 | approvedBy := fmt.Sprintf("%s(%s)", approval.Name, approval.Login) 41 | s += "\n" + checkMark.Render() + " " + doneStyle.Render(fmt.Sprintf("Stage %d - %s already approved by %s\n", i+1, stageRun.Name, approvedBy)) 42 | } else { 43 | approvalsRequired = append(approvalsRequired, pendingApproval{i: i, name: stageRun.Name, state: stageRun.State}) 44 | } 45 | } 46 | 47 | if len(approvalsRequired) <= 0 { 48 | s += "\n" + currentStyle.Render(fmt.Sprintf("No pending approvals for pipeline %s with run id %s\n", name, id)) 49 | fmt.Println(s) 50 | return nil 51 | } 52 | 53 | var options []huh.Option[string] 54 | 55 | for _, approval := range approvalsRequired { 56 | name := fmt.Sprintf("%s - %s", approval.name, approval.state) 57 | options = append(options, huh.NewOption(name, strconv.Itoa(approval.i))) 58 | } 59 | 60 | var approvals []string 61 | 62 | if err := huh.NewMultiSelect[string](). 63 | Options( 64 | options..., 65 | ). 66 | Title("Approvals Required"). 67 | Value(&approvals).Run(); err != nil { 68 | return err 69 | } 70 | 71 | if len(approvals) <= 0 { 72 | s += "\n" + crossMark.Render() + " " + failedStyle.Render("No additional stages approved\n") 73 | 74 | fmt.Println(s) 75 | return nil 76 | } 77 | 78 | cachedStore, err := users.GetCachedTokens() 79 | if err != nil { 80 | return err 81 | } 82 | 83 | approvedBy := fmt.Sprintf("%s(%s)", cachedStore.GithubUser.Name, cachedStore.GithubUser.Login) 84 | 85 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 86 | for _, i := range approvals { 87 | stageNum, _ := strconv.Atoi(i) 88 | stage := pipelineRun.Stages[stageNum] 89 | 90 | s += "\n" + checkMark.Render() + " " + doneStyle.Render(fmt.Sprintf("Stage %d - %s approved by %s\n", stageNum+1, stage.Name, approvedBy)) 91 | 92 | fmt.Println(s) 93 | 94 | pipelineRun.Stages[stageNum].Metadata.Approval = StageRunApproval{Name: cachedStore.GithubUser.Name, Login: cachedStore.GithubUser.Login, Email: cachedStore.GithubUser.Email} 95 | 96 | reason := fmt.Sprintf("Stage Approved %d - %s", stageNum, stage.Name) 97 | if err := audit.Save(context.Background(), AUDIT_APPROVED, resource, cachedStore.GithubUser.Login, cachedStore.GithubUser.Email, reason); err != nil { 98 | return err 99 | } 100 | } 101 | 102 | return savePipelineRun(context.Background(), pipelineRun) 103 | } 104 | 105 | func cancelApprovePipelineRun(name, id string, pipeline *Pipeline, pipelineRun *PipelineRun) error { 106 | s := "" 107 | 108 | type alreadyApproved struct { 109 | i int 110 | name string 111 | state string 112 | } 113 | 114 | var alreadyApprovedStages []alreadyApproved 115 | 116 | for i, stage := range pipeline.Stages { 117 | if !stage.Approval { 118 | continue 119 | } 120 | 121 | stageRun := pipelineRun.Stages[i] 122 | 123 | approval := stageRun.Metadata.Approval 124 | if approval.Name != "" || approval.Login != "" { 125 | if strings.EqualFold(stageRun.State, "PendingApproval") || stageRun.State == "" { 126 | alreadyApprovedStages = append(alreadyApprovedStages, alreadyApproved{i: i, name: stageRun.Name, state: stageRun.State}) 127 | continue 128 | } 129 | 130 | approvedBy := fmt.Sprintf("%s(%s)", approval.Name, approval.Login) 131 | s += "\n" + crossMark.Render() + " " + failedStyle.Render(fmt.Sprintf("Stage %d - %s with state %s cannot be canceled, approved by %s\n", i+1, stageRun.Name, stageRun.State, approvedBy)) 132 | } 133 | } 134 | 135 | if len(alreadyApprovedStages) <= 0 { 136 | s += "\n" + currentStyle.Render(fmt.Sprintf("No approved stages for pipeline %s with run id %s\n", name, id)) 137 | fmt.Println(s) 138 | return nil 139 | } 140 | 141 | var options []huh.Option[string] 142 | 143 | for _, approval := range alreadyApprovedStages { 144 | name := fmt.Sprintf("%s - %s", approval.name, approval.state) 145 | options = append(options, huh.NewOption(name, strconv.Itoa(approval.i))) 146 | } 147 | 148 | var approvals []string 149 | 150 | if err := huh.NewMultiSelect[string](). 151 | Options( 152 | options..., 153 | ). 154 | Title("Cancel Approvals"). 155 | Value(&approvals).Run(); err != nil { 156 | return err 157 | } 158 | 159 | if len(approvals) <= 0 { 160 | s += "\n" + crossMark.Render() + " " + failedStyle.Render("No additional stages approval canceled\n") 161 | 162 | fmt.Println(s) 163 | return nil 164 | } 165 | 166 | cachedStore, err := users.GetCachedTokens() 167 | if err != nil { 168 | return err 169 | } 170 | 171 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 172 | for _, i := range approvals { 173 | stageNum, _ := strconv.Atoi(i) 174 | stage := pipelineRun.Stages[stageNum] 175 | 176 | s += "\n" + checkMark.Render() + " " + doneStyle.Render(fmt.Sprintf("Stage %d - %s canceled approval\n", stageNum+1, stage.Name)) 177 | 178 | fmt.Println(s) 179 | 180 | pipelineRun.Stages[stageNum].Metadata.Approval = StageRunApproval{} 181 | 182 | reason := fmt.Sprintf("Canceled Approval for stage %d - %s", stageNum, stage.Name) 183 | if err := audit.Save(context.Background(), AUDIT_CANCEL_APPROVAL, resource, cachedStore.GithubUser.Login, cachedStore.GithubUser.Email, reason); err != nil { 184 | return err 185 | } 186 | } 187 | 188 | return savePipelineRun(context.Background(), pipelineRun) 189 | } 190 | 191 | func ApprovePipelineRunUI(name, id string) error { 192 | pipeline, err := GetPipeline(context.Background(), name) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | if pipeline.Locked { 198 | resource := map[string]string{"Pipeline": pipeline.Name} 199 | latestAudit, err := audit.Latest(context.Background(), AUDIT_LOCKED, resource) 200 | if err != nil { 201 | return err 202 | } 203 | s := "\n" + crossMark.PaddingRight(1).Render() + 204 | failedStyle.Render("pipeline ") + 205 | warningStyle.Render(name) + 206 | failedStyle.Render(" locked at ") + 207 | warningStyle.Render(latestAudit.Time.String()) + 208 | failedStyle.Render(" by ") + 209 | warningStyle.Render(fmt.Sprintf("%s(%s) ", latestAudit.Actor, latestAudit.Email)) + 210 | warningStyle.Render(fmt.Sprintf(" due to %s ", latestAudit.Message)) + "\n" 211 | fmt.Println(s) 212 | return nil 213 | } 214 | 215 | pipelineRun, err := GetPipelineRun(context.Background(), name, id) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | return approvePipelineRun(name, id, pipeline, pipelineRun) 221 | } 222 | 223 | func CancelApprovePipelineRunUI(name, id string) error { 224 | pipeline, err := GetPipeline(context.Background(), name) 225 | if err != nil { 226 | return err 227 | } 228 | 229 | pipelineRun, err := GetPipelineRun(context.Background(), name, id) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | return cancelApprovePipelineRun(name, id, pipeline, pipelineRun) 235 | } 236 | 237 | func ApprovePipelineRun(ctx context.Context, name, id string, stageNum int) error { 238 | pipeline, err := GetPipeline(ctx, name) 239 | if err != nil { 240 | return err 241 | } 242 | 243 | if pipeline.Locked { 244 | resource := map[string]string{"Pipeline": pipeline.Name} 245 | latestAudit, err := audit.Latest(ctx, AUDIT_LOCKED, resource) 246 | if err != nil { 247 | return fmt.Errorf("Pipeline locked at %s, by %s(%s) due to %s", latestAudit.Time.String(), latestAudit.Actor, latestAudit.Email, latestAudit.Message) 248 | } 249 | return nil 250 | } 251 | 252 | pipelineRun, err := GetPipelineRun(ctx, name, id) 253 | if err != nil { 254 | return err 255 | } 256 | 257 | if stageNum < 0 && stageNum >= len(pipelineRun.Stages) { 258 | return fmt.Errorf("%d invalid stage, choose between 0 and %d", stageNum, len(pipelineRun.Stages)-1) 259 | } 260 | 261 | if pipelineRun.Stages[stageNum].Metadata.Approval.Name != "" { 262 | return nil 263 | } 264 | 265 | userName := ctx.Value(users.NameCtx).(string) 266 | userEmail := ctx.Value(users.EmailCtx).(string) 267 | 268 | pipelineRun.Stages[stageNum].Metadata.Approval = StageRunApproval{Name: userName, Email: userEmail} 269 | 270 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 271 | reason := fmt.Sprintf("Stage Approved %d - %s", stageNum, pipelineRun.Stages[stageNum].Name) 272 | if err := audit.Save(ctx, AUDIT_APPROVED, resource, userName, userEmail, reason); err != nil { 273 | return err 274 | } 275 | 276 | return savePipelineRun(ctx, pipelineRun) 277 | } 278 | 279 | func CancelApprovePipelineRun(ctx context.Context, name, id string, stageNum int) error { 280 | pipelineRun, err := GetPipelineRun(ctx, name, id) 281 | if err != nil { 282 | return err 283 | } 284 | 285 | if stageNum < 0 && stageNum >= len(pipelineRun.Stages) { 286 | return fmt.Errorf("%d invalid stage, choose between 0 and %d", stageNum, len(pipelineRun.Stages)-1) 287 | } 288 | 289 | if pipelineRun.Stages[stageNum].Metadata.Approval.Name == "" { 290 | return nil 291 | } 292 | 293 | if pipelineRun.Stages[stageNum].Metadata.Approval.Name == "" { 294 | return nil 295 | } 296 | 297 | userName := ctx.Value(users.NameCtx).(string) 298 | userEmail := ctx.Value(users.EmailCtx).(string) 299 | 300 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 301 | reason := fmt.Sprintf("Canceled Approval for stage %d - %s", stageNum, pipelineRun.Stages[stageNum].Name) 302 | if err := audit.Save(ctx, AUDIT_APPROVED, resource, userName, userEmail, reason); err != nil { 303 | return err 304 | } 305 | 306 | pipelineRun.Stages[stageNum].Metadata.Approval = StageRunApproval{} 307 | 308 | return savePipelineRun(ctx, pipelineRun) 309 | } 310 | -------------------------------------------------------------------------------- /pipelines/create.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/nixmade/pippy/github" 11 | 12 | "github.com/nixmade/pippy/store" 13 | 14 | "github.com/charmbracelet/huh" 15 | ) 16 | 17 | type Pipeline struct { 18 | Name string `json:"name"` 19 | //GroupStages []GroupStage `json:"group_stages"` 20 | Stages []Stage `json:"stages"` 21 | Locked bool `json:"locked"` 22 | } 23 | 24 | // type GroupStage struct { 25 | // Name string `json:"name"` 26 | // Stages []Stage `json:"stages"` 27 | // } 28 | 29 | type DatadogInfo struct { 30 | Monitors []string `json:"monitors"` 31 | Site string `json:"site"` 32 | ApiKey string `json:"api_key"` 33 | ApplicationKey string `json:"application_key"` 34 | Rollback bool `json:"rollback"` 35 | } 36 | 37 | type WorkflowInfo struct { 38 | Ignore bool `json:"ignore"` 39 | Rollback bool `json:"rollback"` 40 | } 41 | 42 | type MonitorInfo struct { 43 | // Monitor workflow state 44 | Workflow WorkflowInfo `json:"workflow,omitempty"` 45 | Datadog *DatadogInfo `json:"datadog,omitempty"` 46 | } 47 | 48 | type Stage struct { 49 | Repo string `json:"repo"` 50 | Workflow github.Workflow `json:"workflow"` 51 | Approval bool `json:"approval"` 52 | Monitor MonitorInfo `json:"monitor,omitempty"` 53 | Input map[string]string `json:"input,omitempty"` 54 | } 55 | 56 | func GetRepos(repoType string) ([]string, error) { 57 | repos, err := github.DefaultClient.ListRepos(repoType) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | var titles []string 63 | for _, repo := range repos { 64 | titles = append(titles, repo.Name) 65 | } 66 | 67 | return titles, nil 68 | } 69 | 70 | func GetWorkflows(orgRepo string) ([]github.Workflow, error) { 71 | orgRepoSlice := strings.SplitN(orgRepo, "/", 2) 72 | return github.DefaultClient.ListWorkflows(orgRepoSlice[0], orgRepoSlice[1]) 73 | } 74 | 75 | func ValidateWorkflow(orgRepo string, workflow github.Workflow) error { 76 | orgRepoSlice := strings.SplitN(orgRepo, "/", 2) 77 | changes, _, err := github.DefaultClient.ValidateWorkflow(orgRepoSlice[0], orgRepoSlice[1], workflow.Path) 78 | if err != nil { 79 | return nil 80 | } 81 | if len(changes) > 0 { 82 | return fmt.Errorf("this workflow is not pippy ready, please validate using pippy workflow validate") 83 | } 84 | return nil 85 | } 86 | 87 | func getWorkflowTitles(workflows []github.Workflow) []string { 88 | var titles []string 89 | for _, workflow := range workflows { 90 | titles = append(titles, workflow.Name) 91 | } 92 | 93 | return titles 94 | } 95 | 96 | func ListPipelines(ctx context.Context) ([]*Pipeline, error) { 97 | dbStore, err := store.Get(ctx) 98 | if err != nil { 99 | return nil, err 100 | } 101 | defer store.Close(dbStore) 102 | 103 | var pipelines []*Pipeline 104 | pipelineItr := func(key any, value any) error { 105 | pipeline := &Pipeline{} 106 | if err := json.Unmarshal([]byte(value.(string)), pipeline); err != nil { 107 | return err 108 | } 109 | pipelines = append(pipelines, pipeline) 110 | return nil 111 | } 112 | err = dbStore.LoadValues(PipelinePrefix, pipelineItr) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | return pipelines, nil 118 | } 119 | 120 | func GetPipelineCount(ctx context.Context) (uint64, error) { 121 | dbStore, err := store.Get(ctx) 122 | if err != nil { 123 | return 0, err 124 | } 125 | defer store.Close(dbStore) 126 | 127 | count, err := dbStore.Count(PipelinePrefix) 128 | if err != nil { 129 | return 0, err 130 | } 131 | 132 | return count, nil 133 | } 134 | 135 | func GetPipeline(ctx context.Context, name string) (*Pipeline, error) { 136 | dbStore, err := store.Get(ctx) 137 | if err != nil { 138 | return nil, err 139 | } 140 | defer store.Close(dbStore) 141 | 142 | pipeline := &Pipeline{} 143 | 144 | if err := dbStore.LoadJSON(PipelinePrefix+name, pipeline); err != nil { 145 | return nil, err 146 | } 147 | 148 | return pipeline, nil 149 | } 150 | 151 | func SavePipeline(ctx context.Context, pipeline *Pipeline) error { 152 | dbStore, err := store.Get(ctx) 153 | if err != nil { 154 | return err 155 | } 156 | defer store.Close(dbStore) 157 | 158 | return dbStore.SaveJSON(PipelinePrefix+pipeline.Name, pipeline) 159 | } 160 | 161 | func CreatePipeline(name, repoType string) error { 162 | if _, err := GetPipeline(context.Background(), name); err == nil { 163 | return fmt.Errorf("Pipeline %s already exists use pipeline show command", name) 164 | } 165 | 166 | pipeline := Pipeline{Name: name} 167 | 168 | repos, err := GetRepos(repoType) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | workflowCache := make(map[string][]github.Workflow) 174 | 175 | for { 176 | stage := Stage{} 177 | if err := huh.NewForm( 178 | huh.NewGroup( 179 | huh.NewNote(). 180 | Title(fmt.Sprintf("Add a new Stage for Pipeline %s", name)). 181 | Description(fmt.Sprintf("STAGE %d\n", len(pipeline.Stages)+1)), 182 | huh.NewSelect[string](). 183 | Options(huh.NewOptions(repos...)...). 184 | Title("Choose a repo"). 185 | Description("Support public repos only, workflows will be queried in next step"). 186 | Validate(func(t string) error { 187 | return nil 188 | }). 189 | Value(&stage.Repo), 190 | )).Run(); err != nil { 191 | return err 192 | } 193 | 194 | workflows, ok := workflowCache[stage.Repo] 195 | if !ok { 196 | workflows, err = GetWorkflows(stage.Repo) 197 | if err != nil { 198 | return nil 199 | } 200 | 201 | workflowCache[stage.Repo] = workflows 202 | } 203 | 204 | var workflowName string 205 | 206 | if err := huh.NewSelect[string](). 207 | Options(huh.NewOptions(getWorkflowTitles(workflows)...)...). 208 | Title("Choose a workflow"). 209 | Description("Make sure that the workflow support similar inputs across a pipeline"). 210 | Validate(func(t string) error { 211 | for _, workflow := range workflows { 212 | if strings.EqualFold(workflow.Name, t) { 213 | return ValidateWorkflow(stage.Repo, workflow) 214 | } 215 | } 216 | return fmt.Errorf("workflow not found in the list") 217 | }). 218 | Value(&workflowName).Run(); err != nil { 219 | return err 220 | } 221 | 222 | var datadogMonitoring bool 223 | var input string 224 | if err := huh.NewForm( 225 | huh.NewGroup( 226 | huh.NewInput(). 227 | Title("Provide input override? eg: key=value,key1=value1"). 228 | Value(&input), 229 | huh.NewConfirm(). 230 | Title("Approval required?"). 231 | Affirmative("Yes!"). 232 | Negative("No."). 233 | Value(&stage.Approval), 234 | huh.NewConfirm(). 235 | Title("Ignore any failures?"). 236 | Affirmative("Yes!"). 237 | Negative("No."). 238 | Value(&stage.Monitor.Workflow.Ignore), 239 | huh.NewConfirm(). 240 | Title("Rollback on workflow failures?"). 241 | Affirmative("Yes!"). 242 | Negative("No."). 243 | Value(&stage.Monitor.Workflow.Rollback), 244 | huh.NewConfirm(). 245 | Title("Datadog monitoring?"). 246 | Affirmative("Yes!"). 247 | Negative("No."). 248 | Value(&datadogMonitoring), 249 | )).Run(); err != nil { 250 | return err 251 | } 252 | 253 | if datadogMonitoring { 254 | var monitorIds, apiKey, applicationKey string 255 | var rollback bool 256 | site := "datadoghq.com" 257 | if err := huh.NewForm( 258 | huh.NewGroup( 259 | huh.NewNote(). 260 | Title(fmt.Sprintf("Datadog setup %s", name)). 261 | Description("provide monitor ids, api and application key"), 262 | huh.NewInput(). 263 | Title("Monitor ids? eg: monitor_id1,monitor_id2"). 264 | Value(&monitorIds). 265 | Validate(func(t string) error { 266 | if t == "" { 267 | return errors.New("monitor Ids cannot be empty") 268 | } 269 | return nil 270 | }), 271 | huh.NewInput(). 272 | Title("Site"). 273 | Value(&site). 274 | Placeholder("datadoghq.com"), 275 | huh.NewInput(). 276 | Title("API Key"). 277 | Value(&apiKey). 278 | Validate(func(t string) error { 279 | if t == "" { 280 | return errors.New("API Key cannot be empty") 281 | } 282 | return nil 283 | }), 284 | huh.NewInput(). 285 | Title("Application Key"). 286 | Value(&applicationKey). 287 | Validate(func(t string) error { 288 | if t == "" { 289 | return errors.New("application Key cannot be empty") 290 | } 291 | return nil 292 | }), 293 | huh.NewConfirm(). 294 | Title("Rollback on failure?"). 295 | Affirmative("Yes!"). 296 | Negative("No."). 297 | Value(&rollback), 298 | )).Run(); err != nil { 299 | return err 300 | } 301 | ids := strings.Split(monitorIds, ",") 302 | stage.Monitor.Datadog = &DatadogInfo{Monitors: ids, Site: site, ApiKey: apiKey, ApplicationKey: applicationKey, Rollback: rollback} 303 | } 304 | 305 | stage.Input = make(map[string]string) 306 | inputs := strings.Split(input, ",") 307 | for _, str := range inputs { 308 | keyVal := strings.SplitN(str, "=", 2) 309 | if len(keyVal) == 2 { 310 | stage.Input[keyVal[0]] = keyVal[1] 311 | } 312 | } 313 | 314 | for _, workflow := range workflows { 315 | if strings.EqualFold(workflow.Name, workflowName) { 316 | stage.Workflow = workflow 317 | } 318 | } 319 | 320 | pipeline.Stages = append(pipeline.Stages, stage) 321 | 322 | confirm := true 323 | if err := huh.NewConfirm(). 324 | Title("Do you want to specify more stages?"). 325 | Affirmative("Yes!"). 326 | Negative("No."). 327 | Value(&confirm).Run(); err != nil { 328 | return err 329 | } 330 | 331 | if !confirm { 332 | break 333 | } 334 | } 335 | 336 | showPipeline(&pipeline) 337 | 338 | return SavePipeline(context.Background(), &pipeline) 339 | } 340 | -------------------------------------------------------------------------------- /pipelines/create_test.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "testing" 9 | 10 | "github.com/nixmade/pippy/github" 11 | "github.com/nixmade/pippy/store" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | var ( 18 | publicRepos = []github.Repo{ 19 | {Name: "PublicDummy", Url: "DummyUrl", Detail: "This is it"}, 20 | } 21 | privateRepos = []github.Repo{ 22 | {Name: "PrivateDummy", Url: "DummyUrl", Detail: "This is it"}, 23 | } 24 | 25 | expectedWorkflows = map[string][]github.Workflow{ 26 | "org1/repo1": { 27 | {Name: "Workflow1", Id: 1234}, 28 | }, 29 | } 30 | 31 | ErrWorkflowsNotFound = errors.New("workflows not found for org/repo") 32 | ErrUnknownRepoType = errors.New("unknown repo type specified") 33 | ) 34 | 35 | type createGithubClient struct { 36 | } 37 | 38 | func (t *createGithubClient) ListRepos(repoType string) ([]github.Repo, error) { 39 | if repoType == "all" { 40 | return append(privateRepos, publicRepos...), nil 41 | } 42 | if repoType == "private" { 43 | return privateRepos, nil 44 | } 45 | if repoType == "public" { 46 | return publicRepos, nil 47 | } 48 | return nil, ErrUnknownRepoType 49 | } 50 | func (t *createGithubClient) ListWorkflows(org, repo string) ([]github.Workflow, error) { 51 | if workflows, ok := expectedWorkflows[fmt.Sprintf("%s/%s", org, repo)]; ok { 52 | return workflows, nil 53 | } 54 | return nil, ErrWorkflowsNotFound 55 | } 56 | func (t *createGithubClient) ListWorkflowRuns(org, repo string, workflowID int64, created string) ([]github.WorkflowRun, error) { 57 | return nil, nil 58 | } 59 | func (t *createGithubClient) CreateWorkflowDispatch(org, repo string, workflowID int64, ref string, inputs map[string]interface{}) error { 60 | return nil 61 | } 62 | 63 | func (t *createGithubClient) ValidateWorkflow(org, repo, path string) ([]string, map[string]string, error) { 64 | return nil, nil, nil 65 | } 66 | 67 | func (t *createGithubClient) ValidateWorkflowFull(org, repo, path string) (string, string, error) { 68 | return "", "", nil 69 | } 70 | 71 | func (t *createGithubClient) GetWorkflow(org, repo string, id int64) (*github.Workflow, error) { 72 | return nil, nil 73 | } 74 | 75 | func (t *createGithubClient) ListOrgsForUser() ([]github.Org, error) { 76 | return nil, nil 77 | } 78 | 79 | func TestGetRepos(t *testing.T) { 80 | github.DefaultClient = &createGithubClient{} 81 | 82 | repos, err := GetRepos("all") 83 | require.NoError(t, err) 84 | 85 | assert.ElementsMatch(t, []string{"PrivateDummy", "PublicDummy"}, repos, "Actual all repos does not match expected") 86 | 87 | repos, err = GetRepos("public") 88 | require.NoError(t, err) 89 | 90 | assert.ElementsMatch(t, []string{"PublicDummy"}, repos, "Actual public repos does not match expected") 91 | 92 | repos, err = GetRepos("private") 93 | require.NoError(t, err) 94 | 95 | assert.ElementsMatch(t, []string{"PrivateDummy"}, repos, "Actual private repos does not match expected") 96 | 97 | _, err = GetRepos("unknown") 98 | assert.ErrorIs(t, err, ErrUnknownRepoType) 99 | } 100 | 101 | func TestGetWorkflows(t *testing.T) { 102 | github.DefaultClient = &createGithubClient{} 103 | 104 | workflows, err := GetWorkflows("org1/repo1") 105 | 106 | require.NoError(t, err) 107 | 108 | assert.ElementsMatch(t, expectedWorkflows["org1/repo1"], workflows, "Actual workflows does not match expected") 109 | 110 | assert.ElementsMatch(t, []string{"Workflow1"}, getWorkflowTitles(workflows), "Actual workflows titles does not match expected") 111 | 112 | _, err = GetWorkflows("org1/repo2") 113 | 114 | assert.ErrorIs(t, err, ErrWorkflowsNotFound) 115 | } 116 | 117 | func TestSavePipeline(t *testing.T) { 118 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestSavePipeline*") 119 | require.NoError(t, err) 120 | 121 | defer os.RemoveAll(tempDir) 122 | store.HomeDir = tempDir 123 | 124 | pipeline := &Pipeline{ 125 | Name: "Pipeline1", 126 | Stages: []Stage{ 127 | {Repo: "org1/repo1", 128 | Workflow: expectedWorkflows["org1/repo1"][0], 129 | Approval: false, 130 | Input: map[string]string{"version": "dummy2"}}, 131 | }, 132 | } 133 | 134 | require.NoError(t, SavePipeline(context.Background(), pipeline)) 135 | 136 | savedPipeline, err := GetPipeline(context.Background(), pipeline.Name) 137 | require.NoError(t, err) 138 | 139 | assert.Exactlyf(t, pipeline, savedPipeline, "Expected pipeline to match saved Pipeline") 140 | 141 | //os.RemoveAll(helper.homeDir) 142 | } 143 | -------------------------------------------------------------------------------- /pipelines/delete.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/nixmade/pippy/store" 9 | ) 10 | 11 | func DeletePipeline(ctx context.Context, name string) error { 12 | // confirm here that users intention was to delete 13 | dbStore, err := store.Get(ctx) 14 | if err != nil { 15 | return err 16 | } 17 | defer store.Close(dbStore) 18 | 19 | if err := dbStore.Delete(PipelinePrefix + name); err != nil { 20 | if errors.Is(err, store.ErrKeyNotFound) { 21 | return nil 22 | } 23 | return err 24 | } 25 | 26 | if err := dbStore.DeletePrefix(fmt.Sprintf("%s%s/", PipelineRunPrefix, name)); err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func DeletePipelineRun(ctx context.Context, name, id string) error { 34 | // confirm here that users intention was to delete 35 | dbStore, err := store.Get(ctx) 36 | if err != nil { 37 | return err 38 | } 39 | defer store.Close(dbStore) 40 | 41 | pipelineRunKey := fmt.Sprintf("%s%s/%s", PipelineRunPrefix, name, id) 42 | if err := dbStore.Delete(pipelineRunKey); err != nil { 43 | if errors.Is(err, store.ErrKeyNotFound) { 44 | return nil 45 | } 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pipelines/lock.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/nixmade/pippy/audit" 8 | "github.com/nixmade/pippy/users" 9 | ) 10 | 11 | const ( 12 | AUDIT_LOCKED string = "Locked" 13 | AUDIT_UNLOCKED string = "Unlocked" 14 | ) 15 | 16 | func lockUnlockPipelineRun(pipeline *Pipeline, reason string, lock bool) error { 17 | userStore, err := users.GetCachedTokens() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | resource := map[string]string{"Pipeline": pipeline.Name} 23 | if lock { 24 | pipeline.Locked = true 25 | if err := audit.Save(context.Background(), AUDIT_LOCKED, resource, userStore.GithubUser.Login, userStore.GithubUser.Email, reason); err != nil { 26 | return err 27 | } 28 | fmt.Println("\n" + checkMark.Render() + " " + doneStyle.Render("Successfully locked pipeline\n")) 29 | } else { 30 | pipeline.Locked = false 31 | if err := audit.Save(context.Background(), AUDIT_UNLOCKED, resource, userStore.GithubUser.Login, userStore.GithubUser.Email, reason); err != nil { 32 | return err 33 | } 34 | fmt.Println("\n" + checkMark.Render() + " " + doneStyle.Render("Successfully unlocked pipeline\n")) 35 | } 36 | 37 | return SavePipeline(context.Background(), pipeline) 38 | } 39 | 40 | func LockPipelineUI(name, reason string) error { 41 | pipeline, err := GetPipeline(context.Background(), name) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | if pipeline.Locked { 47 | s := "\n" + crossMark.PaddingRight(1).Render() + 48 | failedStyle.Render("pipeline ") + 49 | warningStyle.Render(name) + 50 | failedStyle.Render(" already locked") + "\n" 51 | fmt.Println(s) 52 | return nil 53 | } 54 | 55 | return lockUnlockPipelineRun(pipeline, reason, true) 56 | } 57 | 58 | func UnlockPipelineUI(name, reason string) error { 59 | pipeline, err := GetPipeline(context.Background(), name) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if !pipeline.Locked { 65 | s := "\n" + crossMark.PaddingRight(1).Render() + 66 | failedStyle.Render("pipeline ") + 67 | warningStyle.Render(name) + 68 | failedStyle.Render(" not locked\n") 69 | fmt.Println(s) 70 | return nil 71 | } 72 | 73 | return lockUnlockPipelineRun(pipeline, reason, false) 74 | } 75 | 76 | func LockPipeline(ctx context.Context, name, reason string) error { 77 | pipeline, err := GetPipeline(ctx, name) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | if pipeline.Locked { 83 | return nil 84 | } 85 | 86 | userName := ctx.Value(users.NameCtx).(string) 87 | userEmail := ctx.Value(users.EmailCtx).(string) 88 | 89 | resource := map[string]string{"Pipeline": pipeline.Name} 90 | pipeline.Locked = true 91 | if err := audit.Save(ctx, AUDIT_LOCKED, resource, userName, userEmail, reason); err != nil { 92 | return err 93 | } 94 | 95 | return SavePipeline(ctx, pipeline) 96 | } 97 | 98 | func UnlockPipeline(ctx context.Context, name, reason string) error { 99 | pipeline, err := GetPipeline(ctx, name) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if !pipeline.Locked { 105 | return nil 106 | } 107 | 108 | userName := ctx.Value(users.NameCtx).(string) 109 | userEmail := ctx.Value(users.EmailCtx).(string) 110 | 111 | resource := map[string]string{"Pipeline": pipeline.Name} 112 | pipeline.Locked = false 113 | if err := audit.Save(ctx, AUDIT_UNLOCKED, resource, userName, userEmail, reason); err != nil { 114 | return err 115 | } 116 | 117 | return SavePipeline(ctx, pipeline) 118 | } 119 | -------------------------------------------------------------------------------- /pipelines/orchestrator.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "slices" 8 | "strings" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | "github.com/nixmade/orchestrator/core" 13 | ) 14 | 15 | var ( 16 | ErrReachedTerminalState = errors.New("pipeline rollout reached terminal state") 17 | ErrStageInProgress = errors.New("pipeline stage still in progress") 18 | ) 19 | 20 | func (o *orchestrator) orchestrate(ctx context.Context, interval int) error { 21 | if err := o.setupEngine(); err != nil { 22 | return err 23 | } 24 | defer o.engine.ShutdownAndClose() 25 | 26 | if err := o.tick(ctx, interval); err != nil { 27 | return err 28 | } 29 | 30 | return o.savePipelineRun(ctx) 31 | } 32 | 33 | func (o *orchestrator) tick(ctx context.Context, interval int) error { 34 | ticker := time.NewTicker(time.Duration(interval) * time.Millisecond) 35 | for { 36 | select { 37 | case <-o.done: 38 | o.logger.Info().Msg("orchestrator tick complete") 39 | ticker.Stop() 40 | return nil 41 | case <-ticker.C: 42 | o.logger.Info().Msg("orchestrator tick") 43 | if err := o.savePipelineRun(ctx); err != nil { 44 | return err 45 | } 46 | 47 | if o.paused { 48 | o.stageStatus.UpdateState(PAUSED) 49 | o.logger.Info().Msg("pipeline run paused") 50 | return nil 51 | } 52 | 53 | if o.targetVersion == "" { 54 | o.targetVersion = o.pipelineRunId 55 | } 56 | 57 | for i, stage := range o.pipeline.Stages { 58 | if err := o.stageTick(ctx, i, stage); err != nil { 59 | if errors.Is(err, ErrReachedTerminalState) { 60 | return nil 61 | } 62 | if errors.Is(err, ErrStageInProgress) { 63 | break 64 | } 65 | stageName := getStageName(i, stage.Workflow.Name) 66 | currentRun := o.stageStatus.Get(stageName) 67 | currentRun.reason = err.Error() 68 | currentRun.state = "Workflow_Failed" 69 | o.stageStatus.Set(stageName, currentRun) 70 | return err 71 | } 72 | } 73 | 74 | lastStageNum := len(o.pipeline.Stages) - 1 75 | currentRun := o.stageStatus.Get(getStageName(lastStageNum, o.pipeline.Stages[lastStageNum].Workflow.Name)) 76 | if currentRun.state == "Success" { 77 | o.logger.Info().Msg("Rollout completely successfully") 78 | o.stageStatus.UpdateState(SUCCESS) 79 | return nil 80 | } 81 | } 82 | } 83 | } 84 | 85 | func (o *orchestrator) stageTick(ctx context.Context, i int, stage Stage) error { 86 | stageName := getStageName(i, stage.Workflow.Name) 87 | logger := o.logger.With().Str("Stage", stageName).Logger() 88 | currentRun := o.stageStatus.Get(stageName) 89 | 90 | if currentRun.state == "Success" || currentRun.state == "Failed" { 91 | logger.Info().Str("State", currentRun.state).Msg("rollout already completed for stage") 92 | return nil 93 | } 94 | 95 | if stage.Approval && currentRun.approvedBy == "" { 96 | logger.Info().Msg("Stage pending approval") 97 | currentRun.state = "PendingApproval" 98 | o.stageStatus.Set(stageName, currentRun) 99 | o.stageStatus.UpdateState(PENDING_APPROVAL) 100 | return ErrReachedTerminalState 101 | } 102 | 103 | // get the current state from github for above version 104 | if err := o.getCurrentState(i, stage); err != nil { 105 | return err 106 | } 107 | 108 | target, err := o.getStageTarget(ctx, i, stage) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | stageCurrentRun := o.stageStatus.Get(stageName) 114 | currentRun = stageCurrentRun 115 | if o.rollback != nil { 116 | currentRun = currentRun.rollback 117 | } 118 | targetName := getTargetName(i, stage) 119 | logger.Info().Str("Target", targetName).Str("State", currentRun.state).Msg("orchestrating target") 120 | targets, err := o.engine.Orchestrate(APP_NAME, targetName, []*core.ClientState{target}) 121 | if err != nil { 122 | logger.Error().Err(err).Msg("failed to orchestrate") 123 | return err 124 | } 125 | 126 | if o.rollback != nil { 127 | if currentRun.state == "Workflow_Success" { 128 | // We dont really care much about monitoring since we are in fast rollback mode 129 | currentRun.state = "Success" 130 | o.stageStatus.Set(stageName, stageCurrentRun) 131 | return nil 132 | } 133 | if currentRun.state == "Workflow_Failed" { 134 | currentRun.state = "Failed" 135 | o.stageStatus.Set(stageName, stageCurrentRun) 136 | return nil 137 | } 138 | return o.rolloutExpectedState(i, stage, targets) 139 | } 140 | 141 | rolloutState, err := o.engine.GetRolloutInfo(APP_NAME, targetName) 142 | if err != nil { 143 | logger.Error().Err(err).Msg("failed to get rollout info") 144 | return err 145 | } 146 | 147 | logger.Info(). 148 | Str("LastKnownGoodVersion", rolloutState.LastKnownGoodVersion). 149 | Str("LastKnownBadVersion", rolloutState.LastKnownBadVersion). 150 | Str("TargetVersion", rolloutState.TargetVersion). 151 | Str("RollingVersion", rolloutState.RollingVersion). 152 | Msg("current rollout state") 153 | 154 | if strings.EqualFold(rolloutState.LastKnownGoodVersion, o.targetVersion) { 155 | currentRun.completed = time.Now().UTC() 156 | currentRun.state = "Success" 157 | o.stageStatus.Set(stageName, currentRun) 158 | logger.Info().Str("LastKnownGoodVersion", rolloutState.LastKnownGoodVersion).Msg("rollout completed successfully") 159 | return nil 160 | } 161 | 162 | if strings.EqualFold(rolloutState.LastKnownBadVersion, o.targetVersion) { 163 | if shouldRollback(stage, currentRun) && rolloutState.LastKnownGoodVersion != "" { 164 | // Rollback at this point 165 | logger.Info().Str("LastKnownBadVersion", rolloutState.LastKnownBadVersion).Msg("rolling back due to workflow or monitoring failure") 166 | o.stageRollback(ctx, i, stage, rolloutState.LastKnownGoodVersion) 167 | currentRun = o.stageStatus.Get(stageName) 168 | } 169 | currentRun.completed = time.Now().UTC() 170 | currentRun.state = "Failed" 171 | o.stageStatus.Set(stageName, currentRun) 172 | if currentRun.rollback != nil { 173 | o.stageStatus.UpdateState(ROLLBACK) 174 | } else { 175 | o.stageStatus.UpdateState(FAILED) 176 | } 177 | logger.Info().Str("LastKnownBadVersion", rolloutState.LastKnownBadVersion).Msg("rollout failed") 178 | return ErrReachedTerminalState 179 | } 180 | 181 | if !strings.EqualFold(rolloutState.RollingVersion, o.targetVersion) { 182 | currentRun.state = "ConcurrentError" 183 | currentRun.concurrentRunId = rolloutState.RollingVersion 184 | currentRun.reason = "concurrent rollout ongoing, wait or force version" 185 | o.stageStatus.Set(stageName, currentRun) 186 | o.stageStatus.UpdateState(FAILED) 187 | logger.Info().Str("TargetVersion", o.targetVersion).Str("RollingVersion", rolloutState.RollingVersion).Msg("concurrent rollout ongoing, wait or force version") 188 | // if we get a concurrent rollout error, switch back target version to rolling version 189 | // otherwise we will always end up with concurrent rollout errors 190 | if err := o.engine.SetTargetVersion(APP_NAME, targetName, core.EntityTargetVersion{Version: rolloutState.RollingVersion}); err != nil { 191 | o.logger.Error().Err(err).Msg("failed to set target version") 192 | return err 193 | } 194 | return ErrReachedTerminalState 195 | } 196 | 197 | if err := o.rolloutExpectedState(i, stage, targets); err != nil { 198 | return err 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func shouldRollback(stage Stage, currentRun *run) bool { 205 | if stage.Monitor.Workflow.Rollback && currentRun.state == "Workflow_Failed" { 206 | return true 207 | } 208 | 209 | if stage.Monitor.Datadog != nil && stage.Monitor.Datadog.Rollback { 210 | return true 211 | } 212 | 213 | return false 214 | } 215 | 216 | func (o *orchestrator) getCurrentState(i int, stage Stage) error { 217 | stageName := getStageName(i, stage.Workflow.Name) 218 | logger := o.logger.With().Str("Stage", stageName).Logger() 219 | logger.Info().Msg("getting current stage state") 220 | currentStageRun := o.stageStatus.Get(stageName) 221 | 222 | currentRun := currentStageRun 223 | if o.rollback != nil { 224 | currentRun = currentRun.rollback 225 | } 226 | 227 | // if we have already determined its a success or failure dont do anything 228 | // we might be in monitoring phase 229 | if currentRun.version == o.targetVersion && (currentRun.state == "Workflow_Success" || currentRun.state == "Workflow_Failed") { 230 | logger.Info().Str("CurrentRun", currentRun.version).Str("TargetVersion", o.targetVersion).Msg("using previously stored current run") 231 | return nil 232 | } 233 | 234 | stageRunId := "" 235 | if currentRun.runId == "" { 236 | stageRunId = uuid.New().String() 237 | currentRun.state = "Workflow_Unknown" 238 | currentRun.runId = stageRunId 239 | logger.Info().Str("RunId", stageRunId).Msg("creating new stage run id") 240 | o.stageStatus.Set(stageName, currentRun) 241 | } else { 242 | stageRunId = currentRun.runId 243 | } 244 | 245 | logger = logger.With().Str("RunId", stageRunId).Logger() 246 | 247 | orgRepoSlice := strings.SplitN(stage.Repo, "/", 2) 248 | logger.Info().Str("Org", orgRepoSlice[0]).Str("Repo", orgRepoSlice[1]).Int64("WorkflowId", stage.Workflow.Id).Msg("listing github workflows") 249 | created := fmt.Sprintf(">=%s", o.started) 250 | workflowRuns, err := o.githubClient.ListWorkflowRuns(orgRepoSlice[0], orgRepoSlice[1], stage.Workflow.Id, created) 251 | if err != nil { 252 | logger.Error().Err(err).Str("Org", orgRepoSlice[0]).Str("Repo", orgRepoSlice[1]).Int64("WorkflowId", stage.Workflow.Id).Msg("error listing github workflows") 253 | return err 254 | } 255 | 256 | //currentRun.state = "Workflow_Unknown" 257 | currentRun.runId = stageRunId 258 | currentRun.version = "" 259 | successStates := []string{"completed", "success", "skipped"} 260 | inProgressStates := []string{"in_progress", "queued", "requested", "waiting", "pending"} 261 | for _, workflowRun := range workflowRuns { 262 | if !strings.Contains(workflowRun.Name, stageRunId) { 263 | continue 264 | } 265 | currentRun.runUrl = workflowRun.Url 266 | currentRun.title = workflowRun.Name 267 | currentRun.version = o.targetVersion 268 | if slices.Contains(successStates, workflowRun.Status) { 269 | if workflowRun.Conclusion == "success" || workflowRun.Conclusion == "" || stage.Monitor.Workflow.Ignore { 270 | currentRun.completed = workflowRun.UpdatedAt 271 | currentRun.state = "Workflow_Success" 272 | logger.Info().Str("WorkflowRun", workflowRun.Name).Str("WorkflowRunUrl", workflowRun.Url).Msg("github workflow run completed successfully") 273 | break 274 | } 275 | } 276 | if slices.Contains(inProgressStates, workflowRun.Status) { 277 | currentRun.state = "InProgress" 278 | logger.Info().Str("WorkflowRun", workflowRun.Name).Str("WorkflowRunUrl", workflowRun.Url).Msg("github workflow run still in progress") 279 | break 280 | } 281 | currentRun.completed = workflowRun.UpdatedAt 282 | currentRun.reason = "github workflow run failed" 283 | currentRun.state = "Workflow_Failed" 284 | logger.Info().Str("WorkflowRun", workflowRun.Name).Str("WorkflowRunUrl", workflowRun.Url).Msg("github workflow run failed") 285 | break 286 | } 287 | o.stageStatus.Set(stageName, currentStageRun) 288 | 289 | return nil 290 | } 291 | 292 | func (o *orchestrator) getStageTarget(ctx context.Context, i int, stage Stage) (*core.ClientState, error) { 293 | stageName := getStageName(i, stage.Workflow.Name) 294 | logger := o.logger.With().Str("Stage", stageName).Logger() 295 | targetName := getTargetName(i, stage) 296 | isError := false 297 | currentRun := o.stageStatus.Get(stageName) 298 | if o.rollback != nil { 299 | currentRun = currentRun.rollback 300 | message := fmt.Sprintf("target state %s", currentRun.state) 301 | if currentRun.state == "Workflow_Failed" || currentRun.state == "InProgress" { 302 | isError = true 303 | } 304 | return &core.ClientState{Name: targetName, Version: currentRun.version, IsError: isError, Message: message}, nil 305 | } 306 | message := fmt.Sprintf("target state %s", currentRun.state) 307 | if currentRun.state == "Workflow_Failed" { 308 | isError = true 309 | o.options = &core.RolloutOptions{ 310 | BatchPercent: 1, 311 | SuccessPercent: 100, 312 | SuccessTimeoutSecs: 0, 313 | DurationTimeoutSecs: 0, 314 | } 315 | 316 | logger.Info().EmbedObject(o.options).Msg("resetting rollout options") 317 | if err := o.engine.SetRolloutOptions(APP_NAME, targetName, o.options); err != nil { 318 | logger.Error().Err(err).EmbedObject(o.options).Msg("failed to set rollout options") 319 | return nil, err 320 | } 321 | } else if currentRun.state == "Workflow_Unknown" { 322 | o.options = &core.RolloutOptions{ 323 | BatchPercent: 1, 324 | SuccessPercent: 100, 325 | SuccessTimeoutSecs: 0, 326 | DurationTimeoutSecs: 3600, 327 | } 328 | 329 | if stage.Monitor.Datadog != nil { 330 | // just choosing 15mins for now, later add to datadog info as configurable 331 | o.options.SuccessTimeoutSecs = 900 332 | } 333 | 334 | logger.Info().EmbedObject(o.options).Msg("setting rollout options") 335 | if err := o.engine.SetRolloutOptions(APP_NAME, targetName, o.options); err != nil { 336 | logger.Error().Err(err).EmbedObject(o.options).Msg("failed to set rollout options") 337 | return nil, err 338 | } 339 | 340 | logger.Info().Str("TargetVersion", o.pipelineRunId).Msg("setting target version") 341 | if o.force { 342 | if err := o.engine.ForceTargetVersion(APP_NAME, targetName, core.EntityTargetVersion{Version: o.pipelineRunId}); err != nil { 343 | o.logger.Error().Err(err).Msg("failed to force target version") 344 | return nil, err 345 | } 346 | rolloutState, err := o.engine.GetRolloutInfo(APP_NAME, targetName) 347 | if err != nil { 348 | logger.Error().Err(err).Msg("failed to get rollout info") 349 | return nil, err 350 | } 351 | if rolloutState.LastKnownBadVersion != "" { 352 | if err := CancelPipelineRun(ctx, o.pipeline.Name, rolloutState.LastKnownBadVersion); err != nil { 353 | logger.Error().Str("RunId", rolloutState.LastKnownBadVersion).Err(err).Msg("failed to cancel pipeline run") 354 | return nil, err 355 | } 356 | } 357 | } else { 358 | if err := o.engine.SetTargetVersion(APP_NAME, targetName, core.EntityTargetVersion{Version: o.pipelineRunId}); err != nil { 359 | o.logger.Error().Err(err).Msg("failed to set target version") 360 | return nil, err 361 | } 362 | } 363 | 364 | if stage.Monitor.Datadog != nil { 365 | controller := &MonitoringController{DatadogInfo: stage.Monitor.Datadog} 366 | if err := o.engine.SetEntityMonitoringController(APP_NAME, targetName, controller); err != nil { 367 | o.logger.Error().Err(err).Msg("failed to set monitoring controller") 368 | return nil, err 369 | } 370 | } 371 | } else if currentRun.state == "InProgress" { 372 | isError = true 373 | } 374 | 375 | return &core.ClientState{Name: targetName, Version: currentRun.version, IsError: isError, Message: message}, nil 376 | } 377 | 378 | func (o *orchestrator) rolloutExpectedState(i int, stage Stage, targets []*core.ClientState) error { 379 | stageName := getStageName(i, stage.Workflow.Name) 380 | currentRun := o.stageStatus.Get(stageName) 381 | if o.rollback != nil { 382 | if currentRun.rollback.state == string(IN_PROGRESS) { 383 | return ErrStageInProgress 384 | } 385 | } else if currentRun.state == "InProgress" { 386 | o.stageStatus.UpdateState(IN_PROGRESS) 387 | return ErrStageInProgress 388 | } 389 | 390 | o.logger.Info().Msg("rolling out expected state") 391 | 392 | orgRepoSlice := strings.SplitN(stage.Repo, "/", 2) 393 | targetName := getTargetName(i, stage) 394 | for _, target := range targets { 395 | if !strings.EqualFold(targetName, target.Name) { 396 | continue 397 | } 398 | 399 | stageName := getStageName(i, stage.Workflow.Name) 400 | currentRun := o.stageStatus.Get(stageName) 401 | stageCurrentRun := currentRun 402 | if o.rollback != nil { 403 | currentRun = currentRun.rollback 404 | } 405 | 406 | if strings.EqualFold(currentRun.version, target.Version) { 407 | //fmt.Println(target.Name, target.Version, "Already running required version") 408 | break 409 | } 410 | 411 | if !strings.EqualFold(target.Version, o.targetVersion) { 412 | break 413 | } 414 | 415 | dynamicInputs := o.inputs 416 | if o.rollback != nil { 417 | dynamicInputs = o.rollback.inputs 418 | } 419 | 420 | // default pippy run id 421 | inputs := map[string]interface{}{ 422 | "pippy_run_id": currentRun.runId, 423 | } 424 | 425 | // static key value pair from each stage 426 | for key, value := range stage.Input { 427 | // static values are little smarter 428 | // if they are empty do not add them to the list 429 | // we can override with dynamic key values 430 | if value == "" { 431 | continue 432 | } 433 | inputs[key] = value 434 | } 435 | 436 | // dynamic key value pair provided as input 437 | for key, value := range dynamicInputs { 438 | // if we defined a static value, skip setting it here 439 | // can be accidental by user 440 | if _, ok := inputs[key]; ok { 441 | o.logger.Warn().Str("Stage", targetName).Str("Org", orgRepoSlice[0]).Str("Repo", orgRepoSlice[1]).Int64("WorkflowId", stage.Workflow.Id).Str("Key", key).Msg("setting dynamic value since its defined as static") 442 | } 443 | inputs[key] = value 444 | } 445 | 446 | currentRun.started = time.Now().UTC() 447 | o.logger.Info().Str("Stage", targetName).Str("Org", orgRepoSlice[0]).Str("Repo", orgRepoSlice[1]).Int64("WorkflowId", stage.Workflow.Id).Msg("create a new github workflow run") 448 | if err := o.githubClient.CreateWorkflowDispatch(orgRepoSlice[0], orgRepoSlice[1], stage.Workflow.Id, "main", inputs); err != nil { 449 | o.logger.Error().Err(err).Str("Stage", targetName).Str("Org", orgRepoSlice[0]).Str("Repo", orgRepoSlice[1]).Int64("WorkflowId", stage.Workflow.Id).Msg("failed to create a new github workflow run") 450 | return err 451 | } 452 | currentRun.state = "InProgress" 453 | currentRun.inputs = make(map[string]string) 454 | for key, value := range inputs { 455 | currentRun.inputs[key] = value.(string) 456 | } 457 | o.stageStatus.Set(stageName, stageCurrentRun) 458 | if o.rollback != nil { 459 | o.stageStatus.UpdateState(IN_PROGRESS) 460 | } 461 | return ErrStageInProgress 462 | } 463 | 464 | if currentRun.state != "Success" { 465 | return ErrStageInProgress 466 | } 467 | 468 | return nil 469 | } 470 | 471 | func (o *orchestrator) stageRollback(ctx context.Context, i int, stage Stage, version string) { 472 | o.rollback = &rollbackInfo{} 473 | o.targetVersion = version 474 | // get inputs used during pipeline run 475 | o.stageStatus.UpdateState(ROLLBACK) 476 | 477 | stageName := getStageName(i, stage.Workflow.Name) 478 | currentRun := o.stageStatus.Get(stageName) 479 | if currentRun.rollback == nil { 480 | currentRun.rollback = &run{} 481 | o.stageStatus.Set(stageName, currentRun) 482 | } 483 | 484 | pipelineRun, err := GetPipelineRun(ctx, o.pipeline.Name, version) 485 | if err != nil { 486 | // error during rollback, just mark rollback failure below 487 | o.logger.Error().Str("Version", version).Err(err).Msg("failed to get previous successful pipeline run") 488 | currentRun.rollback.state = string(FAILED) 489 | currentRun.rollback.reason = fmt.Sprintf("failed to get previous successful pipeline run %s", version) 490 | o.stageStatus.Set(stageName, currentRun) 491 | return 492 | } 493 | o.rollback.inputs = pipelineRun.Inputs 494 | 495 | for err := ErrStageInProgress; err == ErrStageInProgress; { 496 | err = o.stageTick(ctx, i, stage) 497 | } 498 | } 499 | 500 | func (o *orchestrator) run(ctx context.Context) { 501 | o.wg.Add(1) 502 | 503 | go func() { 504 | defer o.wg.Done() 505 | if err := o.orchestrate(ctx, 5000); err != nil { 506 | o.logger.Error().Err(err).Msg("Failed to run async orchestrator") 507 | panic(err) 508 | } 509 | o.logger.Info().Msg("orchestrator is done") 510 | }() 511 | } 512 | 513 | func (o *orchestrator) wait() { 514 | o.done <- true 515 | o.logger.Info().Msg("waiting for orchestrator to exit") 516 | o.wg.Wait() 517 | } 518 | -------------------------------------------------------------------------------- /pipelines/pause_resume.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/nixmade/pippy/audit" 8 | "github.com/nixmade/pippy/users" 9 | ) 10 | 11 | const ( 12 | AUDIT_PAUSED string = "Paused" 13 | AUDIT_RESUMED string = "Resumed" 14 | ) 15 | 16 | func pauseResumePipelineRun(pipelineRun *PipelineRun, reason string, pause bool) error { 17 | userStore, err := users.GetCachedTokens() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 23 | if pause { 24 | pipelineRun.Paused = true 25 | 26 | if err := audit.Save(context.Background(), AUDIT_PAUSED, resource, userStore.GithubUser.Login, userStore.GithubUser.Email, reason); err != nil { 27 | return err 28 | } 29 | fmt.Println("\n" + checkMark.Render() + " " + doneStyle.Render("Successfully paused pipeline\n")) 30 | } else { 31 | pipelineRun.Paused = false 32 | latestAudit, err := audit.Latest(context.Background(), AUDIT_PAUSED, resource) 33 | if err != nil { 34 | return err 35 | } 36 | s := "\n" + checkMark.Render() + " " + 37 | doneStyle.Render("Successfully resumed pipeline paused at ") + 38 | warningStyle.Render(latestAudit.Time.String()) + 39 | doneStyle.Render(" by ") + 40 | warningStyle.Render(fmt.Sprintf("%s(%s) ", latestAudit.Actor, latestAudit.Email)) + 41 | doneStyle.Render(" - ") + "\"" + 42 | warningStyle.Render(latestAudit.Message) + "\"\n" 43 | fmt.Println(s) 44 | 45 | if err := audit.Save(context.Background(), AUDIT_RESUMED, resource, userStore.GithubUser.Login, userStore.GithubUser.Email, reason); err != nil { 46 | return err 47 | } 48 | } 49 | 50 | return savePipelineRun(context.Background(), pipelineRun) 51 | } 52 | 53 | func PausePipelineRunUI(name, id, reason string) error { 54 | pipelineRun, err := GetPipelineRun(context.Background(), name, id) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | if pipelineRun.Paused { 60 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 61 | latestAudit, err := audit.Latest(context.Background(), AUDIT_PAUSED, resource) 62 | if err != nil { 63 | return err 64 | } 65 | s := "\n" + crossMark.PaddingRight(1).Render() + 66 | failedStyle.Render("pipeline ") + 67 | warningStyle.Render(name) + 68 | failedStyle.Render(" already paused at ") + 69 | warningStyle.Render(latestAudit.Time.String()) + 70 | failedStyle.Render(" by ") + 71 | warningStyle.Render(fmt.Sprintf("%s(%s) ", latestAudit.Actor, latestAudit.Email)) + 72 | failedStyle.Render("- ") + "\"" + 73 | warningStyle.Render(latestAudit.Message) + "\"\n" 74 | fmt.Println(s) 75 | return nil 76 | } 77 | 78 | return pauseResumePipelineRun(pipelineRun, reason, true) 79 | } 80 | 81 | func ResumePipelineRunUI(name, id, reason string) error { 82 | pipelineRun, err := GetPipelineRun(context.Background(), name, id) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | if !pipelineRun.Paused { 88 | s := "\n" + crossMark.PaddingRight(1).Render() + 89 | failedStyle.Render("pipeline ") + 90 | warningStyle.Render(name) + 91 | failedStyle.Render(" not paused\n") 92 | fmt.Println(s) 93 | return nil 94 | } 95 | 96 | return pauseResumePipelineRun(pipelineRun, reason, false) 97 | } 98 | 99 | func PausePipelineRun(ctx context.Context, name, id, reason string) error { 100 | pipelineRun, err := GetPipelineRun(ctx, name, id) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | if pipelineRun.Paused { 106 | return nil 107 | } 108 | 109 | userName := ctx.Value(users.NameCtx).(string) 110 | userEmail := ctx.Value(users.EmailCtx).(string) 111 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 112 | pipelineRun.Paused = true 113 | 114 | if err := audit.Save(ctx, AUDIT_PAUSED, resource, userName, userEmail, reason); err != nil { 115 | return err 116 | } 117 | 118 | return savePipelineRun(ctx, pipelineRun) 119 | } 120 | 121 | func ResumePipelineRun(ctx context.Context, name, id, reason string) error { 122 | pipelineRun, err := GetPipelineRun(ctx, name, id) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | if !pipelineRun.Paused { 128 | return nil 129 | } 130 | 131 | userName := ctx.Value(users.NameCtx).(string) 132 | userEmail := ctx.Value(users.EmailCtx).(string) 133 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 134 | pipelineRun.Paused = false 135 | if err := audit.Save(ctx, AUDIT_RESUMED, resource, userName, userEmail, reason); err != nil { 136 | return err 137 | } 138 | return savePipelineRun(ctx, pipelineRun) 139 | } 140 | -------------------------------------------------------------------------------- /pipelines/pipeline.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/urfave/cli/v3" 10 | ) 11 | 12 | const ( 13 | PipelinePrefix = "pipeline:" 14 | PipelineRunPrefix = "pipelinerun:" 15 | ) 16 | 17 | func Command() *cli.Command { 18 | return &cli.Command{ 19 | Name: "pipeline", 20 | Usage: "workflow management", 21 | Commands: []*cli.Command{ 22 | { 23 | Name: "create", 24 | Usage: "create pipelines from workflows across single or multiple repos", 25 | Action: func(ctx context.Context, c *cli.Command) error { 26 | if err := CreatePipeline(c.String("name"), c.String("type")); err != nil { 27 | fmt.Printf("%v\n", err) 28 | return err 29 | } 30 | return nil 31 | }, 32 | Flags: []cli.Flag{ 33 | &cli.StringFlag{ 34 | Name: "name", 35 | Usage: "pipeline name", 36 | Required: true, 37 | }, 38 | &cli.StringFlag{ 39 | Name: "type", 40 | Usage: "repo type all, owner, public, private, member", 41 | Required: false, 42 | Value: "owner", 43 | Action: func(ctx context.Context, c *cli.Command, v string) error { 44 | validValues := []string{"all", "owner", "public", "private", "member"} 45 | if slices.Contains(validValues, v) { 46 | return nil 47 | } 48 | return fmt.Errorf("please provide a valid value in %s", strings.Join(validValues, ",")) 49 | }, 50 | }, 51 | }, 52 | }, 53 | { 54 | Name: "list", 55 | Usage: "list pipeline already saved", 56 | Action: func(ctx context.Context, c *cli.Command) error { 57 | if err := ShowAllPipelines(); err != nil { 58 | fmt.Printf("%v\n", err) 59 | return err 60 | } 61 | return nil 62 | }, 63 | }, 64 | { 65 | Name: "delete", 66 | Usage: "delete pipeline already saved", 67 | Action: func(ctx context.Context, c *cli.Command) error { 68 | if err := DeletePipeline(context.Background(), c.String("name")); err != nil { 69 | fmt.Printf("%v\n", err) 70 | return err 71 | } 72 | fmt.Println("\n" + checkMark.Render() + " " + doneStyle.Render("Successfully deleted pipeline\n")) 73 | return nil 74 | }, 75 | Flags: []cli.Flag{ 76 | &cli.StringFlag{ 77 | Name: "name", 78 | Usage: "pipeline name", 79 | Required: true, 80 | }, 81 | }, 82 | }, 83 | { 84 | Name: "show", 85 | Usage: "show pipeline already saved", 86 | Action: func(ctx context.Context, c *cli.Command) error { 87 | if err := ShowPipeline(c.String("name")); err != nil { 88 | fmt.Printf("%v\n", err) 89 | return err 90 | } 91 | return nil 92 | }, 93 | Flags: []cli.Flag{ 94 | &cli.StringFlag{ 95 | Name: "name", 96 | Usage: "pipeline name", 97 | Required: true, 98 | }, 99 | }, 100 | }, 101 | { 102 | Name: "lock", 103 | Usage: "lock pipeline to deny all approvals", 104 | Action: func(ctx context.Context, c *cli.Command) error { 105 | if err := LockPipelineUI(c.String("name"), c.String("reason")); err != nil { 106 | fmt.Printf("%v\n", err) 107 | return err 108 | } 109 | return nil 110 | }, 111 | Flags: []cli.Flag{ 112 | &cli.StringFlag{ 113 | Name: "name", 114 | Usage: "pipeline name", 115 | Required: true, 116 | }, 117 | &cli.StringFlag{ 118 | Name: "reason", 119 | Usage: "lock reason", 120 | Required: true, 121 | }, 122 | }, 123 | }, 124 | { 125 | Name: "unlock", 126 | Usage: "unlock pipeline to allow all approvals", 127 | Action: func(ctx context.Context, c *cli.Command) error { 128 | if err := UnlockPipelineUI(c.String("name"), c.String("reason")); err != nil { 129 | fmt.Printf("%v\n", err) 130 | return err 131 | } 132 | return nil 133 | }, 134 | Flags: []cli.Flag{ 135 | &cli.StringFlag{ 136 | Name: "name", 137 | Usage: "pipeline name", 138 | Required: true, 139 | }, 140 | &cli.StringFlag{ 141 | Name: "reason", 142 | Usage: "lock reason", 143 | Required: true, 144 | }, 145 | }, 146 | }, 147 | { 148 | Name: "run", 149 | Usage: "pipeline runs", 150 | Commands: []*cli.Command{ 151 | { 152 | Name: "execute", 153 | Usage: "run pipeline", 154 | Action: func(ctx context.Context, c *cli.Command) error { 155 | inputs := c.StringSlice("input") 156 | inputPair := parseKeyValuePairs(inputs) 157 | if err := RunPipelineUI(c.String("name"), c.String("id"), inputPair, c.Bool("force")); err != nil { 158 | fmt.Printf("%v\n", err) 159 | return err 160 | } 161 | return nil 162 | }, 163 | Flags: []cli.Flag{ 164 | &cli.StringFlag{ 165 | Name: "name", 166 | Usage: "pipeline name", 167 | Required: true, 168 | }, 169 | &cli.StringSliceFlag{ 170 | Name: "input", 171 | Usage: "pipeline input provided as kv pair, --input version=44ffae --input description='detailed here'", 172 | Required: false, 173 | }, 174 | &cli.StringFlag{ 175 | Name: "id", 176 | Usage: "pipeline run id to resume, blank for a new run", 177 | Value: "", 178 | Required: false, 179 | }, 180 | &cli.BoolFlag{ 181 | Name: "force", 182 | Usage: "use with caution, verify there isnt any other pipeline run in progress, force current version to run", 183 | Value: false, 184 | Required: false, 185 | }, 186 | }, 187 | }, 188 | { 189 | Name: "list", 190 | Usage: "show pipeline runs", 191 | Action: func(ctx context.Context, c *cli.Command) error { 192 | if err := ShowAllPipelineRuns(c.String("name"), c.Int64("count")); err != nil { 193 | fmt.Printf("%v\n", err) 194 | return err 195 | } 196 | return nil 197 | }, 198 | Flags: []cli.Flag{ 199 | &cli.StringFlag{ 200 | Name: "name", 201 | Usage: "pipeline name", 202 | Required: true, 203 | }, 204 | &cli.Int64Flag{ 205 | Name: "count", 206 | Usage: "latest n pipeline runs, -1/0 for all", 207 | Value: 10, 208 | Required: false, 209 | }, 210 | }, 211 | }, 212 | { 213 | Name: "show", 214 | Usage: "show pipeline run details", 215 | Action: func(ctx context.Context, c *cli.Command) error { 216 | if err := ShowPipelineRun(c.String("name"), c.String("id")); err != nil { 217 | fmt.Printf("%v\n", err) 218 | return err 219 | } 220 | return nil 221 | }, 222 | Flags: []cli.Flag{ 223 | &cli.StringFlag{ 224 | Name: "name", 225 | Usage: "pipeline name", 226 | Required: true, 227 | }, 228 | &cli.StringFlag{ 229 | Name: "id", 230 | Usage: "pipeline run id", 231 | Required: true, 232 | }, 233 | }, 234 | }, 235 | { 236 | Name: "approve", 237 | Usage: "approve pipeline run for stage pending approval", 238 | Action: func(ctx context.Context, c *cli.Command) error { 239 | if err := ApprovePipelineRunUI(c.String("name"), c.String("id")); err != nil { 240 | fmt.Printf("%v\n", err) 241 | return err 242 | } 243 | return nil 244 | }, 245 | Flags: []cli.Flag{ 246 | &cli.StringFlag{ 247 | Name: "name", 248 | Usage: "pipeline name", 249 | Required: true, 250 | }, 251 | &cli.StringFlag{ 252 | Name: "id", 253 | Usage: "pipeline run id", 254 | Required: true, 255 | }, 256 | }, 257 | }, 258 | { 259 | Name: "cancel", 260 | Usage: "cancel approval of pipeline run for approved stage", 261 | Action: func(ctx context.Context, c *cli.Command) error { 262 | if err := CancelApprovePipelineRunUI(c.String("name"), c.String("id")); err != nil { 263 | fmt.Printf("%v\n", err) 264 | return err 265 | } 266 | return nil 267 | }, 268 | Flags: []cli.Flag{ 269 | &cli.StringFlag{ 270 | Name: "name", 271 | Usage: "pipeline name", 272 | Required: true, 273 | }, 274 | &cli.StringFlag{ 275 | Name: "id", 276 | Usage: "pipeline run id", 277 | Required: true, 278 | }, 279 | }, 280 | }, 281 | { 282 | Name: "pause", 283 | Usage: "pause pipeline run", 284 | Action: func(ctx context.Context, c *cli.Command) error { 285 | if err := PausePipelineRunUI(c.String("name"), c.String("id"), c.String("reason")); err != nil { 286 | fmt.Printf("%v\n", err) 287 | return err 288 | } 289 | return nil 290 | }, 291 | Flags: []cli.Flag{ 292 | &cli.StringFlag{ 293 | Name: "name", 294 | Usage: "pipeline name", 295 | Required: true, 296 | }, 297 | &cli.StringFlag{ 298 | Name: "id", 299 | Usage: "pipeline run id", 300 | Required: true, 301 | }, 302 | &cli.StringFlag{ 303 | Name: "reason", 304 | Usage: "pipeline pause reason", 305 | Required: true, 306 | }, 307 | }, 308 | }, 309 | { 310 | Name: "resume", 311 | Usage: "resume pipeline run", 312 | Action: func(ctx context.Context, c *cli.Command) error { 313 | if err := ResumePipelineRunUI(c.String("name"), c.String("id"), c.String("reason")); err != nil { 314 | fmt.Printf("%v\n", err) 315 | return err 316 | } 317 | return nil 318 | }, 319 | Flags: []cli.Flag{ 320 | &cli.StringFlag{ 321 | Name: "name", 322 | Usage: "pipeline name", 323 | Required: true, 324 | }, 325 | &cli.StringFlag{ 326 | Name: "id", 327 | Usage: "pipeline run id", 328 | Required: true, 329 | }, 330 | &cli.StringFlag{ 331 | Name: "reason", 332 | Usage: "pipeline resume reason", 333 | Required: true, 334 | }, 335 | }, 336 | }, 337 | }, 338 | }, 339 | }, 340 | } 341 | } 342 | 343 | func parseKeyValuePairs(kvPairs []string) map[string]string { 344 | kvMap := make(map[string]string) 345 | for _, pair := range kvPairs { 346 | parts := strings.SplitN(pair, "=", 2) 347 | if len(parts) == 2 { 348 | kvMap[parts[0]] = parts[1] 349 | } else { 350 | kvMap[parts[0]] = "" 351 | } 352 | } 353 | return kvMap 354 | } 355 | -------------------------------------------------------------------------------- /pipelines/run.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "path" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/nixmade/pippy/github" 16 | "github.com/nixmade/pippy/helpers" 17 | "github.com/nixmade/pippy/log" 18 | "github.com/nixmade/pippy/store" 19 | "github.com/nixmade/pippy/users" 20 | 21 | tea "github.com/charmbracelet/bubbletea" 22 | "github.com/google/uuid" 23 | "github.com/nixmade/orchestrator/core" 24 | "github.com/rs/zerolog" 25 | ) 26 | 27 | type State string 28 | 29 | const ( 30 | APP_NAME = "pippy" 31 | SUCCESS State = "Success" 32 | FAILED State = "Failed" 33 | IN_PROGRESS State = "InProgress" 34 | PENDING_APPROVAL State = "PendingApproval" 35 | PAUSED State = "Paused" 36 | ROLLBACK State = "Rollback" 37 | CONCURRENT_ERROR State = "ConcurrentError" 38 | CANCELED State = "Canceled" 39 | LOCKED State = "Locked" 40 | ) 41 | 42 | type StageRunApproval struct { 43 | Name string `json:"name,omitempty"` 44 | Login string `json:"login,omitempty"` 45 | Email string `json:"email,omitempty"` 46 | } 47 | 48 | type StageRunMetadata struct { 49 | Approval StageRunApproval `json:"approval,omitempty"` 50 | } 51 | 52 | type TriggerMetadata struct { 53 | Name string `json:"name"` 54 | Login string `json:"login"` 55 | Email string `json:"email"` 56 | Reason string `json:"reason"` 57 | } 58 | 59 | type StageRun struct { 60 | Name string `json:"name"` 61 | State string `json:"state"` 62 | Url string `json:"url"` 63 | RunId string `json:"run_id"` 64 | Started time.Time `json:"started"` 65 | Completed time.Time `json:"completed"` 66 | Title string `json:"title"` 67 | Reason string `json:"reason"` 68 | Input map[string]string `json:"input"` 69 | Rollback *StageRun `json:"rollback,omitempty"` 70 | Metadata StageRunMetadata `json:"metadata,omitempty"` 71 | ConcurrentRunId string `json:"concurrent"` 72 | } 73 | 74 | type PipelineRun struct { 75 | Id string `json:"id"` 76 | PipelineName string `json:"name"` 77 | Stages []StageRun `json:"stages"` 78 | State string `json:"state"` 79 | Created time.Time `json:"created"` 80 | Updated time.Time `json:"updated"` 81 | Inputs map[string]string `json:"input"` 82 | Paused bool `json:"paused"` 83 | Version string `json:"version"` 84 | Trigger TriggerMetadata `json:"trigger_metadata"` 85 | } 86 | 87 | type run struct { 88 | state string 89 | runUrl string 90 | runId string 91 | title string 92 | started time.Time 93 | completed time.Time 94 | rollback *run 95 | reason string 96 | approvedBy string 97 | version string 98 | inputs map[string]string 99 | concurrentRunId string 100 | } 101 | 102 | type status struct { 103 | m map[string]*run 104 | state State 105 | lock sync.RWMutex 106 | cache map[string]*run 107 | } 108 | 109 | func (s *status) Set(key string, value *run) { 110 | s.lock.Lock() 111 | defer s.lock.Unlock() 112 | s.m[key] = value 113 | s.cache = make(map[string]*run, len(s.m)) 114 | for k, v := range s.m { 115 | s.cache[k] = deepCopy(&run{}, v) 116 | } 117 | } 118 | 119 | func deepCopy(to *run, from *run) *run { 120 | *to = *from 121 | to.inputs = make(map[string]string, len(from.inputs)) 122 | for k, v := range from.inputs { 123 | to.inputs[k] = v 124 | } 125 | if from.rollback != nil { 126 | to.rollback = deepCopy(&run{}, from.rollback) 127 | } 128 | return to 129 | } 130 | 131 | func (s *status) Get(key string) *run { 132 | s.lock.RLock() 133 | defer s.lock.RUnlock() 134 | value, ok := s.m[key] 135 | if !ok { 136 | return &run{} 137 | } 138 | 139 | return value 140 | } 141 | 142 | func (s *status) GetCache(key string) *run { 143 | s.lock.RLock() 144 | defer s.lock.RUnlock() 145 | 146 | value, ok := s.cache[key] 147 | if !ok { 148 | return &run{} 149 | } 150 | 151 | return value 152 | } 153 | 154 | func (s *status) UpdateState(state State) { 155 | s.lock.Lock() 156 | defer s.lock.Unlock() 157 | s.state = state 158 | } 159 | 160 | func (s *status) GetState() State { 161 | s.lock.Lock() 162 | defer s.lock.Unlock() 163 | return s.state 164 | } 165 | 166 | func getStageName(i int, name string) string { 167 | return fmt.Sprintf("%s-%d", name, i) 168 | } 169 | 170 | func getTargetName(i int, stage Stage) string { 171 | return fmt.Sprintf("%s/%d/%d", stage.Repo, i, stage.Workflow.Id) 172 | } 173 | 174 | func createOrchestrator(ctx context.Context, name, runId string, inputs map[string]string, templateValues map[string]string, trigger TriggerMetadata, force bool) (*orchestrator, error) { 175 | pipeline, err := GetPipeline(ctx, name) 176 | if err != nil { 177 | return nil, err 178 | } 179 | 180 | if runId == "" { 181 | runId = uuid.New().String() 182 | } 183 | 184 | logger := log.Get().With().Str("Pipeline", name).Str("RunId", runId).Logger() 185 | 186 | for _, stage := range pipeline.Stages { 187 | for key, value := range stage.Input { 188 | if templateValue, ok := templateValues[value]; ok { 189 | if _, ok := inputs[key]; ok { 190 | logger.Info().Str("TemplateValue", value).Msg("Skip replacing template value since input is defined") 191 | continue 192 | } 193 | inputs[key] = templateValue 194 | } 195 | } 196 | } 197 | 198 | stageStatus := &status{m: make(map[string]*run)} 199 | o := &orchestrator{ 200 | pipeline: pipeline, 201 | stageStatus: stageStatus, 202 | pipelineRunId: runId, 203 | inputs: inputs, 204 | done: make(chan bool, 1), 205 | logger: &logger, 206 | paused: false, 207 | githubClient: &github.Github{Context: ctx}, 208 | targetVersion: runId, 209 | started: time.Now().UTC(), 210 | force: force, 211 | trigger: trigger, 212 | } 213 | 214 | if err = o.setConfig(ctx); err != nil { 215 | return nil, err 216 | } 217 | 218 | if err := o.loadPipelineRun(ctx); err != nil { 219 | return nil, err 220 | } 221 | 222 | return o, nil 223 | } 224 | 225 | func RunPipeline(ctx context.Context, name, runId string, inputs map[string]string, templateValues map[string]string, trigger TriggerMetadata, force bool) error { 226 | o, err := createOrchestrator(ctx, name, runId, inputs, templateValues, trigger, force) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | currentState := o.stageStatus.GetState() 232 | if currentState == SUCCESS || currentState == FAILED { 233 | o.logger.Warn().Str("State", string(currentState)).Msg("Rollout already completed") 234 | return nil 235 | } 236 | 237 | if err := o.orchestrate(ctx, 5000); err != nil { 238 | o.logger.Error().Err(err).Msg("Failed to run async orchestrator") 239 | //panic(err) 240 | return err 241 | } 242 | o.logger.Info().Msg("orchestrator is done") 243 | 244 | return nil 245 | } 246 | 247 | func RunPipelineUI(name, runId string, inputs map[string]string, force bool) error { 248 | userStore, err := users.GetCachedTokens() 249 | if err != nil { 250 | return err 251 | } 252 | 253 | trigger := TriggerMetadata{Name: userStore.GithubUser.Name, Login: userStore.GithubUser.Login, Email: userStore.GithubUser.Email, Reason: "Manual run"} 254 | o, err := createOrchestrator(context.Background(), name, runId, inputs, nil, trigger, force) 255 | if err != nil { 256 | return err 257 | } 258 | 259 | o.run(context.Background()) 260 | defer o.wait() 261 | 262 | // return nil 263 | p := tea.NewProgram(initialModel(o.pipeline, o.stageStatus, o.started.String(), o.pipelineRunId)) 264 | if _, err := p.Run(); err != nil { 265 | o.logger.Error().Err(err).Msg("error running UI") 266 | return err 267 | } 268 | return nil 269 | } 270 | 271 | type rollbackInfo struct { 272 | inputs map[string]string 273 | } 274 | 275 | type orchestrator struct { 276 | engine *core.Engine 277 | options *core.RolloutOptions 278 | pipeline *Pipeline 279 | started time.Time 280 | stageStatus *status 281 | pipelineRunId string 282 | inputs map[string]string 283 | logger *zerolog.Logger 284 | done chan bool 285 | paused bool 286 | wg sync.WaitGroup 287 | config *core.Config 288 | githubClient github.Client 289 | rollback *rollbackInfo 290 | targetVersion string 291 | force bool 292 | trigger TriggerMetadata 293 | } 294 | 295 | func (o *orchestrator) setConfig(ctx context.Context) error { 296 | homedir, err := store.GetHomeDir() 297 | if err != nil { 298 | o.logger.Error().Err(err).Msg("failed to get home directory") 299 | return err 300 | } 301 | 302 | config := core.NewDefaultConfig() 303 | 304 | config.ApplicationName = APP_NAME 305 | if os.Getenv("DATABASE_URL") != "" { 306 | config.StoreDatabaseURL = os.Getenv("DATABASE_URL") 307 | config.StoreDatabaseSchema = store.PUBLIC_SCHEMA 308 | config.StoreDatabaseTable = "orchestrator" 309 | schema := ctx.Value(store.DatabaseSchemaCtx) 310 | if schema != nil { 311 | config.StoreDatabaseSchema = schema.(string) 312 | } 313 | } else { 314 | config.StoreDirectory = path.Join(homedir, ".pippy", "db", "orchestrator") 315 | } 316 | config.LogDirectory = path.Join(homedir, ".pippy", "logs") 317 | config.LogLevel = "debug" 318 | config.ConsoleLogging = false 319 | o.config = config 320 | 321 | return nil 322 | } 323 | 324 | type MonitoringController struct { 325 | *DatadogInfo 326 | } 327 | 328 | type MonitorState struct { 329 | Name string `json:"name"` 330 | OverallState string `json:"overall_state"` 331 | OverallStateModified string `json:"overall_state_modified"` 332 | } 333 | 334 | func (m *MonitoringController) ExternalMonitoring(_ []*core.ClientState) error { 335 | for _, monitor := range m.Monitors { 336 | monitorID, err := strconv.ParseInt(monitor, 10, 64) 337 | if err != nil { 338 | return fmt.Errorf("failed to parse monitor %s", monitor) 339 | } 340 | 341 | url := fmt.Sprintf("https://api.%s/api/v1/monitor/%d?group_states=all&with_downtimes=true", m.DatadogInfo.Site, monitorID) 342 | headers := map[string]string{ 343 | "Accept": "application/json", 344 | "DD-API-KEY": m.DatadogInfo.ApiKey, 345 | "DD-APPLICATION-KEY": m.DatadogInfo.ApplicationKey, 346 | } 347 | response, err := helpers.HttpGet(url, headers) 348 | if err != nil { 349 | return fmt.Errorf("received error from datadog %s", err) 350 | } 351 | 352 | state := MonitorState{} 353 | if err := json.Unmarshal([]byte(response), &state); err != nil { 354 | return fmt.Errorf("failed to unmarshal datadog response %s", err) 355 | } 356 | 357 | if strings.EqualFold(state.OverallState, "alert") { 358 | return fmt.Errorf("monitor %s in alert state since %s", monitor, state.OverallStateModified) 359 | } 360 | } 361 | 362 | return nil 363 | } 364 | 365 | func (o *orchestrator) setupEngine() error { 366 | var err error 367 | o.logger.Info().Msg("setting up new orchestrator engine") 368 | 369 | o.engine, err = core.NewOrchestratorEngine(o.config) 370 | if err != nil { 371 | return err 372 | } 373 | 374 | core.RegisteredMonitoringControllers = append(core.RegisteredMonitoringControllers, &MonitoringController{}) 375 | 376 | o.options = &core.RolloutOptions{ 377 | BatchPercent: 1, 378 | SuccessPercent: 100, 379 | SuccessTimeoutSecs: 0, 380 | DurationTimeoutSecs: 3600, 381 | } 382 | 383 | if err = o.engine.SetRolloutOptions(APP_NAME, o.pipeline.Name, o.options); err != nil { 384 | o.logger.Error().Err(err).EmbedObject(o.options).Msg("failed to set rollout options") 385 | return err 386 | } 387 | 388 | if err = o.engine.SetTargetVersion(APP_NAME, o.pipeline.Name, core.EntityTargetVersion{Version: o.pipelineRunId}); err != nil { 389 | o.logger.Error().Err(err).Msg("failed to set target version") 390 | return err 391 | } 392 | 393 | o.logger.Info().Msg("new orchestrator engine setup done") 394 | 395 | return nil 396 | } 397 | 398 | func (o *orchestrator) loadPipelineRun(ctx context.Context) error { 399 | pipelineRun, err := GetPipelineRun(ctx, o.pipeline.Name, o.pipelineRunId) 400 | if err != nil { 401 | if errors.Is(err, store.ErrKeyNotFound) { 402 | o.logger.Info().Msg("pipeline run not found, new run") 403 | return nil 404 | } 405 | o.logger.Error().Err(err).Msg("failed to get pipeline run") 406 | return err 407 | } 408 | 409 | o.stageStatus.UpdateState(State(pipelineRun.State)) 410 | o.inputs = pipelineRun.Inputs 411 | o.started = pipelineRun.Created 412 | if pipelineRun.State == string(ROLLBACK) { 413 | o.rollback = &rollbackInfo{} 414 | o.targetVersion = pipelineRun.Version 415 | } 416 | 417 | for i, stageRun := range pipelineRun.Stages { 418 | o.stageStatus.Set(getStageName(i, stageRun.Name), loadStageRun(&stageRun)) 419 | } 420 | 421 | return nil 422 | } 423 | 424 | func setStageRun(stageRun *StageRun, status *run) { 425 | if status == nil { 426 | return 427 | } 428 | stageRun.Input = make(map[string]string) 429 | stageRun.State = status.state 430 | stageRun.Url = status.runUrl 431 | stageRun.RunId = status.runId 432 | stageRun.Title = status.title 433 | stageRun.Started = status.started 434 | stageRun.Completed = status.completed 435 | stageRun.Reason = status.reason 436 | stageRun.ConcurrentRunId = status.concurrentRunId 437 | for key, value := range status.inputs { 438 | stageRun.Input[key] = value 439 | } 440 | if status.rollback != nil { 441 | if stageRun.Rollback == nil { 442 | stageRun.Rollback = &StageRun{Name: stageRun.Name} 443 | } 444 | setStageRun(stageRun.Rollback, status.rollback) 445 | } 446 | } 447 | 448 | func loadStageRun(stageRun *StageRun) *run { 449 | value := &run{ 450 | state: stageRun.State, 451 | runUrl: stageRun.Url, 452 | runId: stageRun.RunId, 453 | title: stageRun.Title, 454 | started: stageRun.Started, 455 | completed: stageRun.Completed, 456 | reason: stageRun.Reason, 457 | concurrentRunId: stageRun.ConcurrentRunId, 458 | } 459 | approval := stageRun.Metadata.Approval 460 | if approval.Name != "" || approval.Login != "" { 461 | value.approvedBy = fmt.Sprintf("%s(%s)", approval.Name, approval.Login) 462 | } 463 | if stageRun.Rollback != nil { 464 | value.rollback = loadStageRun(stageRun.Rollback) 465 | } 466 | return value 467 | } 468 | 469 | func (o *orchestrator) savePipelineRun(ctx context.Context) error { 470 | o.logger.Info().Msg("begin saving pipeline run") 471 | pipelineRun, err := GetPipelineRun(ctx, o.pipeline.Name, o.pipelineRunId) 472 | if errors.Is(err, store.ErrKeyNotFound) { 473 | o.logger.Warn().Msg("pipeline run not found creating new") 474 | pipelineRun = &PipelineRun{Id: o.pipelineRunId, PipelineName: o.pipeline.Name, Paused: false, Created: time.Now().UTC(), Trigger: o.trigger} 475 | } else if err != nil { 476 | return nil 477 | } 478 | 479 | pipelineRun.State = string(o.stageStatus.GetState()) 480 | pipelineRun.Updated = time.Now().UTC() 481 | pipelineRun.Inputs = o.inputs 482 | pipelineRun.Version = o.targetVersion 483 | o.paused = pipelineRun.Paused 484 | o.started = pipelineRun.Created 485 | 486 | var stages []StageRun 487 | for i, stage := range o.pipeline.Stages { 488 | stageName := getStageName(i, stage.Workflow.Name) 489 | stageRun := StageRun{Name: stage.Workflow.Name} 490 | for j, savedStageRun := range pipelineRun.Stages { 491 | if savedStageRun.Name == stage.Workflow.Name && i == j { 492 | stageRun = savedStageRun 493 | } 494 | } 495 | setStageRun(&stageRun, o.stageStatus.Get(stageName)) 496 | stages = append(stages, stageRun) 497 | } 498 | pipelineRun.Stages = stages 499 | 500 | o.logger.Info().Msg("saving pipeline run") 501 | 502 | return savePipelineRun(ctx, pipelineRun) 503 | } 504 | -------------------------------------------------------------------------------- /pipelines/run_test.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | "time" 11 | 12 | "github.com/nixmade/pippy/github" 13 | "github.com/nixmade/pippy/log" 14 | "github.com/nixmade/pippy/store" 15 | 16 | "github.com/google/uuid" 17 | "github.com/nixmade/orchestrator/core" 18 | "github.com/rs/zerolog" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | var ( 24 | defaultTestPipeline = &Pipeline{ 25 | Name: "Pipeline1", 26 | Stages: []Stage{ 27 | {Repo: "org1/repo1", 28 | Workflow: expectedWorkflows["org1/repo1"][0], 29 | Approval: false, 30 | Input: map[string]string{"version": ""}}, 31 | }, 32 | } 33 | ) 34 | 35 | func setConfig(o *orchestrator) { 36 | config := core.NewDefaultConfig() 37 | 38 | config.ApplicationName = "pippy_test" 39 | if testing.Verbose() { 40 | config.LogLevel = "debug" 41 | } else { 42 | config.LogLevel = "fatal" 43 | } 44 | config.ConsoleLogging = true 45 | o.config = config 46 | } 47 | 48 | type dispatch struct { 49 | org, repo string 50 | id int64 51 | inputs map[string]interface{} 52 | } 53 | 54 | type runGithubClient struct { 55 | workflowRuns []github.WorkflowRun 56 | dispatches []dispatch 57 | dispatchErr error 58 | afterDispatch bool 59 | stageStatus *status 60 | } 61 | 62 | func newTestGithubClient() *runGithubClient { 63 | return &runGithubClient{ 64 | dispatchErr: nil, 65 | afterDispatch: false, 66 | } 67 | } 68 | 69 | func (t *runGithubClient) ListRepos(repoType string) ([]github.Repo, error) { 70 | return nil, nil 71 | } 72 | func (t *runGithubClient) ListWorkflows(org, repo string) ([]github.Workflow, error) { 73 | return nil, nil 74 | } 75 | func (t *runGithubClient) ListWorkflowRuns(org, repo string, workflowID int64, created string) ([]github.WorkflowRun, error) { 76 | dispatched := false 77 | for _, dispatch := range t.dispatches { 78 | dispatched = dispatch.id == workflowID 79 | } 80 | if !t.afterDispatch || dispatched { 81 | if t.stageStatus != nil { 82 | status := t.stageStatus.Get(getStageName(0, "Workflow1")) 83 | if status.rollback != nil { 84 | return []github.WorkflowRun{{Name: status.rollback.runId, Status: "completed", Conclusion: "success"}}, nil 85 | } 86 | } 87 | return t.workflowRuns, nil 88 | } 89 | return nil, nil 90 | } 91 | func (t *runGithubClient) CreateWorkflowDispatch(org, repo string, workflowID int64, ref string, inputs map[string]interface{}) error { 92 | t.dispatches = append(t.dispatches, dispatch{org: org, repo: repo, id: workflowID, inputs: maps.Clone(inputs)}) 93 | return t.dispatchErr 94 | } 95 | 96 | func (t *runGithubClient) ValidateWorkflow(org, repo, path string) ([]string, map[string]string, error) { 97 | return nil, nil, nil 98 | } 99 | 100 | func (t *runGithubClient) ValidateWorkflowFull(org, repo, path string) (string, string, error) { 101 | return "", "", nil 102 | } 103 | 104 | func (t *runGithubClient) GetWorkflow(org, repo string, id int64) (*github.Workflow, error) { 105 | return nil, nil 106 | } 107 | 108 | func (t *runGithubClient) ListOrgsForUser() ([]github.Org, error) { 109 | return nil, nil 110 | } 111 | 112 | func setupOrchestrator(*testing.T) *orchestrator { 113 | logger := zerolog.New(os.Stderr).With().Caller().Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr}) 114 | if testing.Verbose() { 115 | logger = logger.Level(zerolog.DebugLevel) 116 | } else { 117 | logger = logger.Level(zerolog.FatalLevel) 118 | } 119 | log.DefaultLogger = &logger 120 | runId := uuid.New().String() 121 | 122 | stageStatus := &status{m: make(map[string]*run)} 123 | o := &orchestrator{ 124 | pipeline: defaultTestPipeline, 125 | stageStatus: stageStatus, 126 | pipelineRunId: runId, 127 | inputs: map[string]string{"version": "dummy2"}, 128 | done: make(chan bool, 1), 129 | logger: &logger, 130 | paused: false, 131 | githubClient: newTestGithubClient(), 132 | targetVersion: runId, 133 | } 134 | setConfig(o) 135 | return o 136 | } 137 | 138 | func TestSaveLoadPipelineRun(t *testing.T) { 139 | o := setupOrchestrator(t) 140 | 141 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestLoadPipelineRun*") 142 | require.NoError(t, err) 143 | 144 | defer os.RemoveAll(tempDir) 145 | store.HomeDir = tempDir 146 | 147 | require.NoError(t, o.loadPipelineRun(context.Background())) 148 | 149 | o.stageStatus.UpdateState(IN_PROGRESS) 150 | stageName := getStageName(0, "Workflow1") 151 | stageRunId := uuid.NewString() 152 | value := run{ 153 | state: string(IN_PROGRESS), 154 | runUrl: "dummyurl", 155 | runId: stageRunId, 156 | title: "test title this is cool!", 157 | } 158 | 159 | o.stageStatus.Set(stageName, &value) 160 | 161 | require.NoError(t, o.savePipelineRun(context.Background())) 162 | o.stageStatus.UpdateState(PENDING_APPROVAL) 163 | o.stageStatus.Set(stageName, &run{}) 164 | 165 | require.NoError(t, o.loadPipelineRun(context.Background())) 166 | 167 | stageRun := o.stageStatus.Get(stageName) 168 | require.NotNil(t, stageRun) 169 | 170 | assert.Equal(t, "test title this is cool!", stageRun.title) 171 | assert.Equal(t, stageRunId, stageRun.runId) 172 | } 173 | 174 | func TestOrchestrateGood(t *testing.T) { 175 | o := setupOrchestrator(t) 176 | 177 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateGood*") 178 | require.NoError(t, err) 179 | 180 | defer os.RemoveAll(tempDir) 181 | store.HomeDir = tempDir 182 | 183 | newPipeline := &Pipeline{ 184 | Name: "Pipeline1", 185 | Stages: []Stage{ 186 | {Repo: "org1/repo1", 187 | Workflow: expectedWorkflows["org1/repo1"][0], 188 | Approval: false, 189 | Input: map[string]string{"version": ""}}, 190 | {Repo: "org1/repo1", 191 | Workflow: github.Workflow{Name: "Workflow2", Id: 2345}, 192 | Approval: false, 193 | Input: map[string]string{"version": ""}}, 194 | }, 195 | } 196 | o.pipeline = newPipeline 197 | 198 | var runs []github.WorkflowRun 199 | for i, stage := range newPipeline.Stages { 200 | err = o.getCurrentState(i, stage) 201 | require.NoError(t, err) 202 | 203 | status := o.stageStatus.Get(getStageName(i, stage.Workflow.Name)) 204 | require.NotNil(t, status) 205 | runs = append(runs, github.WorkflowRun{Name: status.runId, Status: "completed", Conclusion: ""}) 206 | } 207 | 208 | githubClient := &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 209 | o.githubClient = githubClient 210 | require.NoError(t, o.orchestrate(context.Background(), 1)) 211 | 212 | require.Equal(t, SUCCESS, o.stageStatus.GetState()) 213 | require.Len(t, githubClient.dispatches, 2) 214 | } 215 | 216 | func TestOrchestrateBad(t *testing.T) { 217 | o := setupOrchestrator(t) 218 | 219 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateBad*") 220 | require.NoError(t, err) 221 | 222 | defer os.RemoveAll(tempDir) 223 | store.HomeDir = tempDir 224 | 225 | newPipeline := &Pipeline{ 226 | Name: "Pipeline1", 227 | Stages: []Stage{ 228 | {Repo: "org1/repo1", 229 | Workflow: expectedWorkflows["org1/repo1"][0], 230 | Approval: false, 231 | Input: map[string]string{"version": ""}}, 232 | {Repo: "org1/repo1", 233 | Workflow: github.Workflow{Name: "Workflow2", Id: 2345}, 234 | Approval: false, 235 | Input: map[string]string{"version": ""}}, 236 | }, 237 | } 238 | o.pipeline = newPipeline 239 | o.githubClient = newTestGithubClient() 240 | var runs []github.WorkflowRun 241 | for i, stage := range newPipeline.Stages { 242 | err = o.getCurrentState(i, stage) 243 | require.NoError(t, err) 244 | 245 | status := o.stageStatus.Get(getStageName(i, stage.Workflow.Name)) 246 | require.NotNil(t, status) 247 | runs = append(runs, github.WorkflowRun{Name: status.runId, Status: "completed", Conclusion: "failure"}) 248 | } 249 | 250 | githubClient := &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 251 | o.githubClient = githubClient 252 | require.NoError(t, o.orchestrate(context.Background(), 1)) 253 | 254 | require.Equal(t, FAILED, o.stageStatus.GetState()) 255 | 256 | currentRun := o.stageStatus.Get(getStageName(1, newPipeline.Stages[1].Workflow.Name)) 257 | require.NotNil(t, currentRun) 258 | require.Equal(t, "Workflow_Unknown", currentRun.state) 259 | require.Len(t, githubClient.dispatches, 1) 260 | } 261 | 262 | func TestOrchestrateIgnoreFailures(t *testing.T) { 263 | o := setupOrchestrator(t) 264 | 265 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateBad*") 266 | require.NoError(t, err) 267 | 268 | defer os.RemoveAll(tempDir) 269 | store.HomeDir = tempDir 270 | 271 | newPipeline := &Pipeline{ 272 | Name: "Pipeline1", 273 | Stages: []Stage{ 274 | {Repo: "org1/repo1", 275 | Workflow: expectedWorkflows["org1/repo1"][0], 276 | Approval: false, 277 | Input: map[string]string{"version": ""}, 278 | Monitor: MonitorInfo{Workflow: WorkflowInfo{Ignore: true}}}, 279 | {Repo: "org1/repo1", 280 | Workflow: github.Workflow{Name: "Workflow2", Id: 2345}, 281 | Approval: false, 282 | Input: map[string]string{"version": ""}, 283 | Monitor: MonitorInfo{Workflow: WorkflowInfo{Ignore: true}}}, 284 | }, 285 | } 286 | o.pipeline = newPipeline 287 | o.githubClient = newTestGithubClient() 288 | var runs []github.WorkflowRun 289 | for i, stage := range newPipeline.Stages { 290 | err = o.getCurrentState(i, stage) 291 | require.NoError(t, err) 292 | 293 | status := o.stageStatus.Get(getStageName(i, stage.Workflow.Name)) 294 | require.NotNil(t, status) 295 | runs = append(runs, github.WorkflowRun{Name: status.runId, Status: "completed", Conclusion: "failure"}) 296 | } 297 | 298 | githubClient := &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 299 | o.githubClient = githubClient 300 | require.NoError(t, o.orchestrate(context.Background(), 1)) 301 | 302 | require.Equal(t, SUCCESS, o.stageStatus.GetState()) 303 | 304 | for i, stage := range newPipeline.Stages { 305 | currentRun := o.stageStatus.Get(getStageName(i, stage.Workflow.Name)) 306 | require.NotNil(t, currentRun) 307 | assert.Equal(t, "Success", currentRun.state) 308 | } 309 | require.Len(t, githubClient.dispatches, 2) 310 | } 311 | 312 | func TestOrchestrateApproval(t *testing.T) { 313 | o := setupOrchestrator(t) 314 | 315 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateApproval*") 316 | require.NoError(t, err) 317 | 318 | defer os.RemoveAll(tempDir) 319 | store.HomeDir = tempDir 320 | 321 | newPipeline := &Pipeline{ 322 | Name: "Pipeline1", 323 | Stages: []Stage{ 324 | {Repo: "org1/repo1", 325 | Workflow: expectedWorkflows["org1/repo1"][0], 326 | Approval: false, 327 | Input: map[string]string{"version": ""}}, 328 | {Repo: "org1/repo1", 329 | Workflow: github.Workflow{Name: "Workflow2", Id: 2345}, 330 | Approval: true, 331 | Input: map[string]string{"version": ""}}, 332 | }, 333 | } 334 | o.pipeline = newPipeline 335 | setConfig(o) 336 | testGithubClient := newTestGithubClient() 337 | o.githubClient = testGithubClient 338 | err = o.getCurrentState(0, newPipeline.Stages[0]) 339 | require.NoError(t, err) 340 | 341 | status := o.stageStatus.Get(getStageName(0, "Workflow1")) 342 | require.NotNil(t, status) 343 | runs := []github.WorkflowRun{ 344 | {Name: status.runId, Status: "completed", Conclusion: ""}, 345 | } 346 | testGithubClient = &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 347 | o.githubClient = testGithubClient 348 | 349 | require.NoError(t, o.orchestrate(context.Background(), 1)) 350 | 351 | require.Equal(t, PENDING_APPROVAL, o.stageStatus.GetState()) 352 | require.Len(t, testGithubClient.dispatches, 1) 353 | } 354 | 355 | func TestOrchestratePaused(t *testing.T) { 356 | o := setupOrchestrator(t) 357 | 358 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestratePaused*") 359 | require.NoError(t, err) 360 | 361 | defer os.RemoveAll(tempDir) 362 | store.HomeDir = tempDir 363 | 364 | o.githubClient = newTestGithubClient() 365 | 366 | require.NoError(t, o.savePipelineRun(context.Background())) 367 | 368 | pipelineRun, err := GetPipelineRun(context.Background(), defaultTestPipeline.Name, o.pipelineRunId) 369 | require.NoError(t, err) 370 | 371 | pipelineRun.Paused = true 372 | require.NoError(t, savePipelineRun(context.Background(), pipelineRun)) 373 | 374 | require.NoError(t, o.orchestrate(context.Background(), 1)) 375 | 376 | assert.Equal(t, PAUSED, o.stageStatus.GetState()) 377 | } 378 | 379 | func TestOrchestrateApprovalMulti(t *testing.T) { 380 | o := setupOrchestrator(t) 381 | 382 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateApproval*") 383 | require.NoError(t, err) 384 | 385 | defer os.RemoveAll(tempDir) 386 | store.HomeDir = tempDir 387 | 388 | newPipeline := &Pipeline{ 389 | Name: "Pipeline1", 390 | Stages: []Stage{ 391 | {Repo: "org1/repo1", 392 | Workflow: expectedWorkflows["org1/repo1"][0], 393 | Approval: false, 394 | Input: map[string]string{"version": ""}}, 395 | {Repo: "org1/repo1", 396 | Workflow: github.Workflow{Name: "Workflow2", Id: 2345}, 397 | Approval: true, 398 | Input: map[string]string{"version": ""}}, 399 | }, 400 | } 401 | o.pipeline = newPipeline 402 | { 403 | 404 | testGithubClient := newTestGithubClient() 405 | o.githubClient = testGithubClient 406 | err = o.getCurrentState(0, newPipeline.Stages[0]) 407 | require.NoError(t, err) 408 | 409 | status := o.stageStatus.Get(getStageName(0, "Workflow1")) 410 | require.NotNil(t, status) 411 | runs := []github.WorkflowRun{ 412 | {Name: status.runId, Status: "completed", Conclusion: ""}, 413 | } 414 | testGithubClient = &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 415 | o.githubClient = testGithubClient 416 | 417 | require.NoError(t, o.orchestrate(context.Background(), 1)) 418 | 419 | require.Equal(t, PENDING_APPROVAL, o.stageStatus.GetState()) 420 | require.Len(t, testGithubClient.dispatches, 1) 421 | } 422 | 423 | o2 := setupOrchestrator(t) 424 | o2.pipeline = newPipeline 425 | setConfig(o2) 426 | { 427 | testGithubClient := newTestGithubClient() 428 | o2.githubClient = testGithubClient 429 | err = o2.getCurrentState(0, newPipeline.Stages[0]) 430 | require.NoError(t, err) 431 | 432 | status := o2.stageStatus.Get(getStageName(0, "Workflow1")) 433 | require.NotNil(t, status) 434 | runs := []github.WorkflowRun{ 435 | {Name: status.runId, Status: "completed", Conclusion: "failure"}, 436 | } 437 | testGithubClient = &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 438 | o2.githubClient = testGithubClient 439 | 440 | require.NoError(t, o2.orchestrate(context.Background(), 1)) 441 | 442 | status = o2.stageStatus.Get(getStageName(0, "Workflow1")) 443 | require.NotNil(t, status) 444 | assert.Equal(t, "Failed", status.state) 445 | assert.Equal(t, FAILED, o2.stageStatus.GetState()) 446 | require.Len(t, testGithubClient.dispatches, 1) 447 | } 448 | 449 | } 450 | 451 | func TestOrchestrateRollback(t *testing.T) { 452 | o := setupOrchestrator(t) 453 | 454 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateRollback*") 455 | require.NoError(t, err) 456 | 457 | defer os.RemoveAll(tempDir) 458 | store.HomeDir = tempDir 459 | 460 | o.config.StoreDirectory = filepath.Join(tempDir, "orchestrator") 461 | require.NoError(t, os.MkdirAll(o.config.StoreDirectory, os.ModePerm)) 462 | 463 | newPipeline := &Pipeline{ 464 | Name: "Pipeline1", 465 | Stages: []Stage{ 466 | {Repo: "org1/repo1", 467 | Workflow: expectedWorkflows["org1/repo1"][0], 468 | Approval: false, 469 | Monitor: MonitorInfo{Workflow: WorkflowInfo{Rollback: true, Ignore: false}}, 470 | Input: map[string]string{"version": ""}}, 471 | }, 472 | } 473 | o.pipeline = newPipeline 474 | 475 | err = o.getCurrentState(0, newPipeline.Stages[0]) 476 | require.NoError(t, err) 477 | 478 | stageRun := o.stageStatus.Get(getStageName(0, "Workflow1")) 479 | require.NotNil(t, stageRun) 480 | 481 | runs := []github.WorkflowRun{ 482 | {Name: stageRun.runId, Status: "completed", Conclusion: ""}, 483 | } 484 | 485 | githubClient := &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 486 | o.githubClient = githubClient 487 | require.NoError(t, o.orchestrate(context.Background(), 1)) 488 | 489 | assert.Equal(t, SUCCESS, o.stageStatus.GetState()) 490 | 491 | // LKG is established, lets try to rollout new 492 | prevRunId := o.pipelineRunId 493 | runId := uuid.New().String() 494 | o.pipelineRunId = runId 495 | o.targetVersion = runId 496 | o.stageStatus = &status{m: make(map[string]*run)} 497 | o.inputs = map[string]string{"version": "dummy4"} 498 | 499 | err = o.getCurrentState(0, defaultTestPipeline.Stages[0]) 500 | require.NoError(t, err) 501 | 502 | stageRun = o.stageStatus.Get(getStageName(0, "Workflow1")) 503 | require.Equal(t, "Workflow_Unknown", stageRun.state) 504 | 505 | stageRun = o.stageStatus.Get(getStageName(0, "Workflow1")) 506 | require.NotNil(t, stageRun) 507 | 508 | runs = []github.WorkflowRun{ 509 | {Name: stageRun.runId, Status: "completed", Conclusion: "failure"}, 510 | } 511 | 512 | githubClient = &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true, stageStatus: o.stageStatus} 513 | o.githubClient = githubClient 514 | 515 | // Need to get the right run id here for rollback 516 | // otherwise we enter an infinite loop 517 | require.NoError(t, o.orchestrate(context.Background(), 1)) 518 | 519 | stageRun = o.stageStatus.Get(getStageName(0, "Workflow1")) 520 | require.NotNil(t, stageRun) 521 | require.NotNil(t, stageRun.rollback) 522 | 523 | require.Equal(t, string(FAILED), stageRun.state) 524 | require.Equal(t, runId, stageRun.version) 525 | require.Equal(t, prevRunId, stageRun.rollback.version) 526 | require.Equal(t, "dummy4", stageRun.inputs["version"]) 527 | require.Equal(t, "dummy2", stageRun.rollback.inputs["version"]) 528 | require.Len(t, githubClient.dispatches, 2) 529 | } 530 | 531 | func TestOrchestrateBadDispatchErr(t *testing.T) { 532 | o := setupOrchestrator(t) 533 | 534 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateBad*") 535 | require.NoError(t, err) 536 | 537 | defer os.RemoveAll(tempDir) 538 | store.HomeDir = tempDir 539 | 540 | newPipeline := &Pipeline{ 541 | Name: "Pipeline1", 542 | Stages: []Stage{ 543 | {Repo: "org1/repo1", 544 | Workflow: expectedWorkflows["org1/repo1"][0], 545 | Approval: false, 546 | Input: map[string]string{"version": ""}}, 547 | {Repo: "org1/repo1", 548 | Workflow: github.Workflow{Name: "Workflow2", Id: 2345}, 549 | Approval: false, 550 | Input: map[string]string{"version": ""}}, 551 | }, 552 | } 553 | o.pipeline = newPipeline 554 | o.githubClient = newTestGithubClient() 555 | //var runs []github.WorkflowRun 556 | for i, stage := range newPipeline.Stages { 557 | err = o.getCurrentState(i, stage) 558 | require.NoError(t, err) 559 | 560 | status := o.stageStatus.Get(getStageName(i, stage.Workflow.Name)) 561 | require.NotNil(t, status) 562 | //runs = append(runs, github.WorkflowRun{Name: status.runId, Status: "completed", Conclusion: "failure"}) 563 | } 564 | 565 | githubClient := &runGithubClient{dispatchErr: fmt.Errorf("simulating dispatch error"), workflowRuns: nil, afterDispatch: true} 566 | o.githubClient = githubClient 567 | require.NoError(t, o.setupEngine()) 568 | defer o.engine.ShutdownAndClose() 569 | 570 | require.Error(t, o.tick(context.Background(), 1)) 571 | time.Sleep(1 * time.Second) 572 | require.NoError(t, o.tick(context.Background(), 1)) 573 | 574 | require.Equal(t, FAILED, o.stageStatus.GetState()) 575 | 576 | currentRun := o.stageStatus.Get(getStageName(0, newPipeline.Stages[0].Workflow.Name)) 577 | require.Equal(t, "simulating dispatch error", currentRun.reason) 578 | 579 | currentRun = o.stageStatus.Get(getStageName(1, newPipeline.Stages[1].Workflow.Name)) 580 | require.NotNil(t, currentRun) 581 | require.Equal(t, "Workflow_Unknown", currentRun.state) 582 | require.Len(t, githubClient.dispatches, 1) 583 | } 584 | 585 | func TestOrchestrateConcurrentError(t *testing.T) { 586 | o := setupOrchestrator(t) 587 | 588 | tempDir, err := os.MkdirTemp(os.TempDir(), "TestOrchestrateConcurrentError*") 589 | require.NoError(t, err) 590 | 591 | defer os.RemoveAll(tempDir) 592 | store.HomeDir = tempDir 593 | 594 | newPipeline := &Pipeline{ 595 | Name: "Pipeline1", 596 | Stages: []Stage{ 597 | {Repo: "org1/repo1", 598 | Workflow: expectedWorkflows["org1/repo1"][0], 599 | Approval: false, 600 | Input: map[string]string{"version": ""}}, 601 | {Repo: "org1/repo1", 602 | Workflow: github.Workflow{Name: "Workflow2", Id: 2345}, 603 | Approval: false, 604 | Input: map[string]string{"version": ""}}, 605 | }, 606 | } 607 | oldRunId := o.pipelineRunId 608 | o.pipeline = newPipeline 609 | o.githubClient = newTestGithubClient() 610 | var runs []github.WorkflowRun 611 | for i, stage := range newPipeline.Stages { 612 | err = o.getCurrentState(i, stage) 613 | require.NoError(t, err) 614 | 615 | status := o.stageStatus.Get(getStageName(i, stage.Workflow.Name)) 616 | require.NotNil(t, status) 617 | runs = append(runs, github.WorkflowRun{Name: status.runId, Status: "in_progress", Conclusion: ""}) 618 | } 619 | 620 | require.NoError(t, o.setupEngine()) 621 | defer o.engine.ShutdownAndClose() 622 | 623 | githubClient := &runGithubClient{dispatchErr: nil, workflowRuns: runs, afterDispatch: true} 624 | o.githubClient = githubClient 625 | require.ErrorIs(t, o.stageTick(context.Background(), 0, newPipeline.Stages[0]), ErrStageInProgress) 626 | require.ErrorIs(t, o.stageTick(context.Background(), 0, newPipeline.Stages[0]), ErrStageInProgress) 627 | 628 | require.Equal(t, IN_PROGRESS, o.stageStatus.GetState()) 629 | 630 | currentRun := o.stageStatus.Get(getStageName(0, newPipeline.Stages[0].Workflow.Name)) 631 | require.NotNil(t, currentRun) 632 | require.Equal(t, "InProgress", currentRun.state) 633 | require.Len(t, githubClient.dispatches, 1) 634 | 635 | runId := uuid.NewString() 636 | o.pipelineRunId = runId 637 | o.targetVersion = runId 638 | 639 | require.ErrorIs(t, o.stageTick(context.Background(), 0, newPipeline.Stages[0]), ErrReachedTerminalState) 640 | currentRun = o.stageStatus.Get(getStageName(0, newPipeline.Stages[0].Workflow.Name)) 641 | require.NotNil(t, currentRun) 642 | require.Equal(t, "ConcurrentError", currentRun.state) 643 | 644 | o.pipelineRunId = oldRunId 645 | o.targetVersion = oldRunId 646 | var newRuns []github.WorkflowRun 647 | for _, run := range githubClient.workflowRuns { 648 | newRuns = append(newRuns, github.WorkflowRun{Name: run.Name, Status: "completed", Conclusion: "success"}) 649 | } 650 | githubClient.workflowRuns = newRuns 651 | 652 | require.ErrorIs(t, o.stageTick(context.Background(), 0, newPipeline.Stages[0]), ErrStageInProgress) 653 | time.Sleep(1 * time.Second) 654 | require.NoError(t, o.stageTick(context.Background(), 0, newPipeline.Stages[0])) 655 | currentRun = o.stageStatus.Get(getStageName(0, newPipeline.Stages[0].Workflow.Name)) 656 | require.NotNil(t, currentRun) 657 | require.Equal(t, "Success", currentRun.state) 658 | 659 | newRunId := uuid.NewString() 660 | o.pipelineRunId = newRunId 661 | o.targetVersion = newRunId 662 | githubClient = newTestGithubClient() 663 | o.githubClient = githubClient 664 | 665 | currentRun.state = "Workflow_Unknown" 666 | o.stageStatus.Set(getStageName(0, newPipeline.Stages[0].Workflow.Name), currentRun) 667 | require.ErrorIs(t, o.stageTick(context.Background(), 0, newPipeline.Stages[0]), ErrStageInProgress) 668 | require.ErrorIs(t, o.stageTick(context.Background(), 0, newPipeline.Stages[0]), ErrStageInProgress) 669 | currentRun = o.stageStatus.Get(getStageName(0, newPipeline.Stages[0].Workflow.Name)) 670 | require.NotNil(t, currentRun) 671 | require.Equal(t, "InProgress", currentRun.state) 672 | require.Len(t, githubClient.dispatches, 1) 673 | } 674 | -------------------------------------------------------------------------------- /pipelines/run_ui.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | var ( 14 | currentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) 15 | doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00c468")) 16 | failedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#c4002a")) 17 | warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffe57f")) 18 | waitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#808080")) 19 | descriptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) 20 | checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") 21 | crossMark = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#f44336", Dark: "#cc0000"}).SetString("x") 22 | bulletMark = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}).SetString("•") 23 | clockMark = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#ffe57f", Dark: "#ffcc00"}).SetString("⌛") 24 | rollbackMark = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#f44336", Dark: "#cc0000"}).SetString("⎌") 25 | ) 26 | 27 | type model struct { 28 | name string 29 | runId string 30 | spinner spinner.Model 31 | stages []string 32 | startedAt string 33 | stageStatus *status 34 | } 35 | 36 | func initialModel(pipeline *Pipeline, stageStatus *status, startedAt string, runId string) model { 37 | var stages []string 38 | for _, stage := range pipeline.Stages { 39 | stages = append(stages, stage.Workflow.Name) 40 | } 41 | s := spinner.New() 42 | s.Spinner = spinner.Dot 43 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 44 | return model{ 45 | stages: stages, 46 | name: pipeline.Name, 47 | runId: runId, 48 | spinner: s, 49 | startedAt: startedAt, 50 | stageStatus: stageStatus, 51 | } 52 | } 53 | 54 | func (m model) Init() tea.Cmd { 55 | return m.spinner.Tick 56 | } 57 | 58 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 59 | switch msg := msg.(type) { 60 | case tea.KeyMsg: 61 | switch msg.String() { 62 | case "ctrl+c", "q": 63 | return m, tea.Quit 64 | } 65 | default: 66 | switch m.stageStatus.GetState() { 67 | case SUCCESS, FAILED, PAUSED, LOCKED, ROLLBACK: 68 | return m, tea.Quit 69 | default: 70 | var cmd tea.Cmd 71 | m.spinner, cmd = m.spinner.Update(msg) 72 | return m, cmd 73 | } 74 | } 75 | 76 | return m, nil 77 | } 78 | 79 | func (m model) View() string { 80 | s := currentStyle.Render(fmt.Sprintf("Running pipeline %s using run id %s, started at %s", m.name, m.runId, m.startedAt)) + "\n\n" 81 | 82 | for i, stageName := range m.stages { 83 | if status := m.stageStatus.GetCache(getStageName(i, stageName)); status != nil { 84 | title := stageName 85 | if status.title != "" { 86 | title = status.title 87 | } 88 | if strings.EqualFold(status.state, "Success") { 89 | s += checkMark.PaddingRight(1).Render(title) + " " + doneStyle.Render(status.completed.Sub(status.started).String()) 90 | s += descriptionStyle.Faint(true).Render("\n " + status.runUrl) 91 | if status.approvedBy != "" { 92 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(status.approvedBy) 93 | } 94 | s += "\n" 95 | continue 96 | } else if strings.EqualFold(status.state, "InProgress") { 97 | s += m.spinner.View() + " " + currentStyle.Render(title) + " " + currentStyle.Render(time.Now().UTC().Sub(status.started).String()) 98 | s += descriptionStyle.Faint(true).Render("\n " + status.runUrl) 99 | if status.approvedBy != "" { 100 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(status.approvedBy) 101 | } 102 | if status.rollback != nil { 103 | rollbackTitle := stageName 104 | if status.rollback.title != "" { 105 | rollbackTitle = status.rollback.title 106 | } 107 | s += descriptionStyle.Faint(true).Render("\n Rollback ") + status.rollback.state + " " + doneStyle.Render(rollbackTitle) 108 | s += descriptionStyle.Faint(true).Render("\n ") + doneStyle.Render(status.rollback.runUrl) 109 | } 110 | s += "\n" 111 | continue 112 | } else if strings.EqualFold(status.state, "Failed") { 113 | if status.rollback != nil { 114 | s += rollbackMark.Render() + " " + failedStyle.Render(title) + " " + failedStyle.Render(status.completed.Sub(status.started).String()) 115 | } else { 116 | s += crossMark.Render() + " " + failedStyle.Render(title) + " " + failedStyle.Render(status.completed.Sub(status.started).String()) 117 | } 118 | s += descriptionStyle.Faint(true).Render("\n " + status.runUrl) 119 | if status.approvedBy != "" { 120 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(status.approvedBy) 121 | } 122 | if status.rollback != nil { 123 | s += warningStyle.Faint(true).Render("\n Rollback " + status.rollback.state + " " + status.rollback.title) 124 | s += warningStyle.Faint(true).Render("\n " + status.rollback.runUrl) 125 | } 126 | s += "\n" 127 | continue 128 | } else if strings.EqualFold(status.state, "PendingApproval") { 129 | s += clockMark.Render() + " " + warningStyle.Render(stageName) 130 | if status.approvedBy != "" { 131 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(status.approvedBy) 132 | } 133 | s += "\n" 134 | continue 135 | } else if strings.EqualFold(status.state, "ConcurrentError") { 136 | s += crossMark.Render() + " " + failedStyle.Render(title) 137 | s += failedStyle.Render("\n Another pipeline run in progress, " + status.concurrentRunId) 138 | s += failedStyle.Render("\n DANGER! optionally force this version using --force") 139 | s += "\n" 140 | continue 141 | } 142 | 143 | } 144 | s += bulletMark.Render() + " " + waitStyle.Render(stageName) + "\n" 145 | } 146 | 147 | if m.stageStatus.GetState() == SUCCESS { 148 | s += "\n" + checkMark.Render() + " " + doneStyle.Render(fmt.Sprintf("Successfully completed running pipeline %s with run id %s\n", m.name, m.runId)) 149 | } 150 | 151 | if m.stageStatus.GetState() == FAILED { 152 | s += "\n" + crossMark.Render() + " " + failedStyle.Render(fmt.Sprintf("Failed running pipeline %s with run id %s\n", m.name, m.runId)) 153 | } 154 | 155 | if m.stageStatus.GetState() == ROLLBACK { 156 | s += "\n" + rollbackMark.Render() + " " + failedStyle.Render(fmt.Sprintf("Rollback complete for pipeline %s with run id %s\n", m.name, m.runId)) 157 | } 158 | 159 | if m.stageStatus.GetState() == PENDING_APPROVAL { 160 | s += "\n" + clockMark.Render() + " " + warningStyle.Render(fmt.Sprintf("Pending approval for pipeline %s with run id %s\n", m.name, m.runId)) 161 | } 162 | 163 | if m.stageStatus.GetState() == PAUSED { 164 | s += "\n" + clockMark.Render() + " " + warningStyle.Render(fmt.Sprintf("Paused pipeline %s with run id %s\n", m.name, m.runId)) 165 | } 166 | 167 | return s 168 | } 169 | -------------------------------------------------------------------------------- /pipelines/runs.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/nixmade/pippy/audit" 14 | "github.com/nixmade/pippy/store" 15 | 16 | "github.com/charmbracelet/lipgloss" 17 | "github.com/charmbracelet/lipgloss/table" 18 | ) 19 | 20 | func displayInputs(inputs map[string]string) string { 21 | var output []string 22 | for key, value := range inputs { 23 | output = append(output, fmt.Sprintf("%s=%s", key, value)) 24 | } 25 | return strings.Join(output, ",") 26 | } 27 | 28 | func listPipelineRuns(pipelineRuns []*PipelineRun) { 29 | rows := [][]string{} 30 | for _, pipelineRun := range pipelineRuns { 31 | rows = append(rows, []string{pipelineRun.Created.Format(time.RFC3339), pipelineRun.Id, pipelineRun.State, pipelineRun.Updated.Sub(pipelineRun.Created).String(), displayInputs(pipelineRun.Inputs)}) 32 | } 33 | 34 | sort.Slice(rows, func(i, j int) bool { 35 | leftTime, err := time.Parse(time.RFC3339, rows[i][0]) 36 | if err != nil { 37 | return false 38 | } 39 | 40 | rightTime, err := time.Parse(time.RFC3339, rows[j][0]) 41 | if err != nil { 42 | return false 43 | } 44 | 45 | return leftTime.After(rightTime) 46 | }) 47 | 48 | re := lipgloss.NewRenderer(os.Stdout) 49 | 50 | var ( 51 | // HeaderStyle is the lipgloss style used for the table headers. 52 | HeaderStyle = re.NewStyle().Foreground(purple).Bold(true).Align(lipgloss.Center) 53 | // CellStyle is the base lipgloss style used for the table rows. 54 | CellStyle = re.NewStyle().Padding(0, 1).Width(14) 55 | // OddRowStyle is the lipgloss style used for odd-numbered table rows. 56 | OddRowStyle = CellStyle.Foreground(bright) 57 | // EvenRowStyle is the lipgloss style used for even-numbered table rows. 58 | EvenRowStyle = CellStyle.Foreground(dim) 59 | // BorderStyle is the lipgloss style used for the table border. 60 | BorderStyle = lipgloss.NewStyle().Foreground(dim) 61 | ) 62 | 63 | t := table.New(). 64 | Width(120). 65 | Border(lipgloss.RoundedBorder()). 66 | BorderStyle(BorderStyle). 67 | Headers("TIME", "ID", "STATE", "RUN TIME", "INPUTS"). 68 | Rows(rows...). 69 | StyleFunc(func(row, col int) lipgloss.Style { 70 | var style lipgloss.Style 71 | switch { 72 | case row == 0: 73 | style = HeaderStyle 74 | case row%2 == 0: 75 | style = EvenRowStyle 76 | default: 77 | style = OddRowStyle 78 | } 79 | 80 | if col == 0 { 81 | style = style.Width(18) 82 | } 83 | if col == 1 { 84 | style = style.Width(36) 85 | } 86 | if col == 2 { 87 | style = style.Width(8) 88 | } 89 | if col == 4 { 90 | style = style.Width(20) 91 | } 92 | return style 93 | }) 94 | 95 | fmt.Println(t) 96 | } 97 | 98 | func showPipelineRun(name, id string, pipelineRun *PipelineRun) { 99 | s := currentStyle.Render(fmt.Sprintf("Pipeline %s with run id %s started at %s", name, id, pipelineRun.Created.String())) + "\n\n" 100 | 101 | for _, stage := range pipelineRun.Stages { 102 | var approvedBy string 103 | approval := stage.Metadata.Approval 104 | if approval.Name != "" || approval.Login != "" { 105 | approvedBy = fmt.Sprintf("%s(%s)", approval.Name, approval.Login) 106 | } 107 | if strings.EqualFold(stage.State, "Success") { 108 | s += checkMark.PaddingRight(1).Render(stage.Title) + " " + doneStyle.Render(stage.Completed.Sub(stage.Started).String()) 109 | s += descriptionStyle.Faint(true).Render("\n " + stage.Url) 110 | if approvedBy != "" { 111 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(approvedBy) 112 | } 113 | s += "\n" 114 | continue 115 | } else if strings.EqualFold(stage.State, "InProgress") { 116 | s += bulletMark.Render() + " " + currentStyle.Render(stage.Title) + " " + currentStyle.Render(stage.Completed.Sub(stage.Started).String()) 117 | s += descriptionStyle.Faint(true).Render("\n " + stage.Url) 118 | if approvedBy != "" { 119 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(approvedBy) 120 | } 121 | if stage.Rollback != nil { 122 | s += descriptionStyle.Faint(true).Render("\n Rollback " + stage.Rollback.State + " " + stage.Rollback.Title) 123 | s += descriptionStyle.Faint(true).Render("\n " + stage.Rollback.Url) 124 | } 125 | s += "\n" 126 | continue 127 | } else if strings.EqualFold(stage.State, "Failed") { 128 | if stage.Rollback != nil { 129 | s += rollbackMark.Render() + " " + failedStyle.Render(stage.Title) + " " + failedStyle.Render(stage.Completed.Sub(stage.Started).String()) 130 | } else { 131 | s += crossMark.Render() + " " + failedStyle.Render(stage.Title) + " " + failedStyle.Render(stage.Completed.Sub(stage.Started).String()) 132 | } 133 | s += descriptionStyle.Faint(true).Render("\n " + stage.Url) 134 | if approvedBy != "" { 135 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(approvedBy) 136 | } 137 | if stage.Rollback != nil { 138 | s += warningStyle.Faint(true).Render("\n Rollback " + stage.Rollback.State + " " + stage.Rollback.Title) 139 | s += warningStyle.Faint(true).Render("\n " + stage.Rollback.Url) 140 | } 141 | s += "\n" 142 | continue 143 | } else if strings.EqualFold(stage.State, "PendingApproval") { 144 | s += clockMark.Render() + " " + warningStyle.Render(stage.Name) 145 | if approvedBy != "" { 146 | s += descriptionStyle.Faint(true).Render("\n Approved by ") + doneStyle.Render(approvedBy) 147 | } 148 | s += "\n" 149 | continue 150 | } 151 | s += bulletMark.Render() + " " + waitStyle.Render(stage.Name) + "\n" 152 | } 153 | 154 | if State(pipelineRun.State) == SUCCESS { 155 | s += "\n" + checkMark.Render() + " " + doneStyle.Render(fmt.Sprintf("Successfully completed running at %s", pipelineRun.Updated.String())) 156 | } 157 | 158 | if State(pipelineRun.State) == FAILED { 159 | s += "\n" + crossMark.Render() + " " + failedStyle.Render(fmt.Sprintf("Failed running pipeline at %s", pipelineRun.Updated.String())) 160 | } 161 | 162 | if State(pipelineRun.State) == ROLLBACK { 163 | s += "\n" + rollbackMark.Render() + " " + failedStyle.Render(fmt.Sprintf("Rollback complete for pipeline at %s", pipelineRun.Updated.String())) 164 | } 165 | 166 | if State(pipelineRun.State) == PENDING_APPROVAL { 167 | s += "\n" + clockMark.Render() + " " + warningStyle.Render(fmt.Sprintf("Pending approval for pipeline at %s", pipelineRun.Updated.String())) 168 | } 169 | 170 | if State(pipelineRun.State) == PAUSED { 171 | resource := map[string]string{"Pipeline": pipelineRun.PipelineName, "PipelineRun": pipelineRun.Id} 172 | latestAudit, err := audit.Latest(context.Background(), AUDIT_PAUSED, resource) 173 | if err == nil { 174 | s += "\n" + clockMark.Render() + " " + warningStyle.Render(fmt.Sprintf("Paused pipeline at %s by %s", latestAudit.Time.String(), fmt.Sprintf("%s(%s) ", latestAudit.Actor, latestAudit.Email))) 175 | } 176 | } 177 | 178 | fmt.Println(s) 179 | } 180 | 181 | func GetPipelineRuns(ctx context.Context, name string) ([]*PipelineRun, error) { 182 | return GetPipelineRunsN(ctx, name, -1) 183 | } 184 | 185 | func GetPipelineRunsN(ctx context.Context, name string, limit int64) ([]*PipelineRun, error) { 186 | dbStore, err := store.Get(ctx) 187 | if err != nil { 188 | return nil, err 189 | } 190 | defer store.Close(dbStore) 191 | 192 | pipelineRunKeyPrefix := PipelineRunPrefix 193 | if name != "" { 194 | pipelineRunKeyPrefix = fmt.Sprintf("%s%s/", PipelineRunPrefix, name) 195 | } 196 | var pipelineRuns []*PipelineRun 197 | pipelineRunItr := func(key any, value any) error { 198 | pipelineRun := &PipelineRun{} 199 | if err := json.Unmarshal([]byte(value.(string)), pipelineRun); err != nil { 200 | return err 201 | } 202 | pipelineRuns = append(pipelineRuns, pipelineRun) 203 | return nil 204 | } 205 | err = dbStore.SortedDescN(pipelineRunKeyPrefix, "$.created", limit, pipelineRunItr) 206 | if err != nil { 207 | return nil, err 208 | } 209 | 210 | return pipelineRuns, nil 211 | } 212 | 213 | func GetPipelineRunCount(ctx context.Context, name string) (uint64, error) { 214 | dbStore, err := store.Get(ctx) 215 | if err != nil { 216 | return 0, err 217 | } 218 | defer store.Close(dbStore) 219 | 220 | pipelineRunKeyPrefix := PipelineRunPrefix 221 | if name != "" { 222 | pipelineRunKeyPrefix = fmt.Sprintf("%s%s/", PipelineRunPrefix, name) 223 | } 224 | count, err := dbStore.Count(pipelineRunKeyPrefix) 225 | if err != nil { 226 | return 0, err 227 | } 228 | 229 | return count, nil 230 | } 231 | 232 | func GetPipelineRunCountByState(ctx context.Context, name string) (map[string]int64, error) { 233 | dbStore, err := store.Get(ctx) 234 | if err != nil { 235 | return nil, err 236 | } 237 | defer store.Close(dbStore) 238 | 239 | pipelineRunKeyPrefix := PipelineRunPrefix 240 | if name != "" { 241 | pipelineRunKeyPrefix = fmt.Sprintf("%s%s/", PipelineRunPrefix, name) 242 | } 243 | counts := make(map[string]int64) 244 | countsItr := func(key any, value any) error { 245 | counts[key.(string)] = value.(int64) 246 | return nil 247 | } 248 | err = dbStore.CountJsonPath(pipelineRunKeyPrefix, "$.state", countsItr) 249 | if err != nil { 250 | return nil, err 251 | } 252 | 253 | return counts, nil 254 | } 255 | 256 | func GetPipelineRun(ctx context.Context, name, id string) (*PipelineRun, error) { 257 | dbStore, err := store.Get(ctx) 258 | if err != nil { 259 | return nil, err 260 | } 261 | defer store.Close(dbStore) 262 | 263 | pipelineRunKey := fmt.Sprintf("%s%s/%s", PipelineRunPrefix, name, id) 264 | pipelineRun := &PipelineRun{} 265 | 266 | if err = dbStore.LoadJSON(pipelineRunKey, pipelineRun); err != nil { 267 | return nil, err 268 | } 269 | 270 | return pipelineRun, nil 271 | } 272 | 273 | func CancelPipelineRun(ctx context.Context, name, id string) error { 274 | pipelineRun, err := GetPipelineRun(ctx, name, id) 275 | if err != nil { 276 | if errors.Is(err, store.ErrKeyNotFound) { 277 | // if the pipeline run is found there is nothing to set 278 | return nil 279 | } 280 | return err 281 | } 282 | 283 | if pipelineRun.State != string(SUCCESS) && pipelineRun.State != string(FAILED) { 284 | pipelineRun.State = string(CANCELED) 285 | return savePipelineRun(ctx, pipelineRun) 286 | } 287 | 288 | return nil 289 | } 290 | 291 | func savePipelineRun(ctx context.Context, run *PipelineRun) error { 292 | dbStore, err := store.Get(ctx) 293 | if err != nil { 294 | return err 295 | } 296 | defer store.Close(dbStore) 297 | 298 | pipelineRunKey := fmt.Sprintf("%s%s/%s", PipelineRunPrefix, run.PipelineName, run.Id) 299 | return dbStore.SaveJSON(pipelineRunKey, run) 300 | } 301 | 302 | func ShowAllPipelineRuns(name string, limit int64) error { 303 | pipelineRuns, err := GetPipelineRunsN(context.Background(), name, limit) 304 | if err != nil { 305 | return err 306 | } 307 | 308 | listPipelineRuns(pipelineRuns) 309 | 310 | return nil 311 | } 312 | 313 | func ShowPipelineRun(name, id string) error { 314 | pipelineRun, err := GetPipelineRun(context.Background(), name, id) 315 | if err != nil { 316 | if errors.Is(err, store.ErrKeyNotFound) { 317 | s := "\n" + crossMark.PaddingRight(1).Render() + 318 | failedStyle.Render("pipeline run ") + 319 | warningStyle.Render(id) + 320 | failedStyle.Render(" for pipeline ") + 321 | warningStyle.Render(name) + 322 | failedStyle.Render(" not found\n") 323 | fmt.Println(s) 324 | return nil 325 | } 326 | } 327 | 328 | showPipelineRun(name, id, pipelineRun) 329 | 330 | return nil 331 | } 332 | -------------------------------------------------------------------------------- /pipelines/show.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/nixmade/pippy/store" 11 | 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/charmbracelet/lipgloss/table" 14 | ) 15 | 16 | const ( 17 | purple = lipgloss.Color("#929292") 18 | bright = lipgloss.Color("#FDFF90") 19 | dim = lipgloss.Color("#97AD64") 20 | ) 21 | 22 | func showPipeline(pipeline *Pipeline) { 23 | rows := [][]string{} 24 | for i, stage := range pipeline.Stages { 25 | approval := "NO" 26 | if stage.Approval { 27 | approval = "YES" 28 | if pipeline.Locked { 29 | approval = "LOCKED" 30 | } 31 | } 32 | ignore := "NO" 33 | rollback := "" 34 | if stage.Monitor.Workflow.Ignore { 35 | ignore = "YES" 36 | } 37 | if stage.Monitor.Workflow.Rollback { 38 | rollback = "WORKFLOW" 39 | } 40 | datadog := "NO" 41 | if stage.Monitor.Datadog != nil { 42 | datadog = "YES" 43 | if stage.Monitor.Datadog.Rollback { 44 | if rollback == "" { 45 | rollback = "DATADOG" 46 | } else { 47 | rollback += ",DATADOG" 48 | } 49 | } 50 | } 51 | 52 | if rollback == "" { 53 | rollback = "NO" 54 | } 55 | 56 | rows = append(rows, []string{strconv.Itoa(i + 1), stage.Repo, stage.Workflow.Name, stage.Workflow.Url, approval, ignore, datadog, rollback}) 57 | } 58 | 59 | re := lipgloss.NewRenderer(os.Stdout) 60 | 61 | var ( 62 | // HeaderStyle is the lipgloss style used for the table headers. 63 | HeaderStyle = re.NewStyle().Foreground(purple).Bold(true).Align(lipgloss.Center) 64 | // CellStyle is the base lipgloss style used for the table rows. 65 | CellStyle = re.NewStyle().Padding(0, 1).Width(14) 66 | // OddRowStyle is the lipgloss style used for odd-numbered table rows. 67 | OddRowStyle = CellStyle.Foreground(bright) 68 | // EvenRowStyle is the lipgloss style used for even-numbered table rows. 69 | EvenRowStyle = CellStyle.Foreground(dim) 70 | // BorderStyle is the lipgloss style used for the table border. 71 | BorderStyle = lipgloss.NewStyle().Foreground(dim) 72 | ) 73 | 74 | t := table.New(). 75 | Width(120). 76 | Border(lipgloss.RoundedBorder()). 77 | BorderStyle(BorderStyle). 78 | Headers("#", "REPO", "WORKFLOW", "URL", "REQUIRES APPROVAL", "IGNORE WORKFLOW FAILURES", "DATADOG MONITORING", "ROLLBACK"). 79 | Rows(rows...). 80 | StyleFunc(func(row, col int) lipgloss.Style { 81 | var style lipgloss.Style 82 | switch { 83 | case row == 0: 84 | style = HeaderStyle 85 | case row%2 == 0: 86 | style = EvenRowStyle 87 | default: 88 | style = OddRowStyle 89 | } 90 | if col == 0 { 91 | style = style.Width(2) 92 | } 93 | if col == 1 { 94 | style = style.Width(8) 95 | } 96 | if col == 2 { 97 | style = style.Width(10) 98 | } 99 | if col == 3 { 100 | style = style.Width(16) 101 | } 102 | if col == 4 { 103 | style = style.Width(20) 104 | } 105 | if col == 5 { 106 | style = style.Width(26) 107 | } 108 | if col == 6 { 109 | style = style.Width(20) 110 | } 111 | if col == 7 { 112 | style = style.Width(10) 113 | } 114 | 115 | return style.AlignHorizontal(lipgloss.Center) 116 | }) 117 | 118 | fmt.Println(t) 119 | } 120 | 121 | func ShowPipeline(name string) error { 122 | pipeline, err := GetPipeline(context.Background(), name) 123 | if err != nil { 124 | if errors.Is(err, store.ErrKeyNotFound) { 125 | s := "\n" + crossMark.PaddingRight(1).Render() + 126 | failedStyle.Render("pipeline ") + 127 | warningStyle.Render(name) + 128 | failedStyle.Render(" not found\n") 129 | fmt.Println(s) 130 | return nil 131 | } 132 | return err 133 | } 134 | 135 | showPipeline(pipeline) 136 | 137 | return nil 138 | } 139 | 140 | func listPipeline(pipelines []*Pipeline) error { 141 | rows := [][]string{} 142 | for i, pipeline := range pipelines { 143 | locked := "NO" 144 | if pipeline.Locked { 145 | locked = "YES" 146 | } 147 | runs, err := GetPipelineRunCountByState(context.Background(), pipeline.Name) 148 | if err != nil { 149 | return err 150 | } 151 | runCount := 0 152 | for _, value := range runs { 153 | runCount += int(value) 154 | } 155 | rows = append(rows, []string{strconv.Itoa(i + 1), pipeline.Name, strconv.Itoa(len(pipeline.Stages)), strconv.Itoa(runCount), locked}) 156 | } 157 | 158 | re := lipgloss.NewRenderer(os.Stdout) 159 | 160 | var ( 161 | // HeaderStyle is the lipgloss style used for the table headers. 162 | HeaderStyle = re.NewStyle().Foreground(purple).Bold(true).Align(lipgloss.Center) 163 | // CellStyle is the base lipgloss style used for the table rows. 164 | CellStyle = re.NewStyle().Padding(0, 1).Width(14) 165 | // OddRowStyle is the lipgloss style used for odd-numbered table rows. 166 | OddRowStyle = CellStyle.Foreground(bright) 167 | // EvenRowStyle is the lipgloss style used for even-numbered table rows. 168 | EvenRowStyle = CellStyle.Foreground(dim) 169 | // BorderStyle is the lipgloss style used for the table border. 170 | BorderStyle = lipgloss.NewStyle().Foreground(dim) 171 | ) 172 | 173 | t := table.New(). 174 | Width(120). 175 | Border(lipgloss.RoundedBorder()). 176 | BorderStyle(BorderStyle). 177 | Headers("#", "NAME", "STAGES", "RUNS", "APPROVALS LOCKED"). 178 | Rows(rows...). 179 | StyleFunc(func(row, col int) lipgloss.Style { 180 | var style lipgloss.Style 181 | switch { 182 | case row == 0: 183 | style = HeaderStyle 184 | case row%2 == 0: 185 | style = EvenRowStyle 186 | default: 187 | style = OddRowStyle 188 | } 189 | if col == 0 { 190 | style = style.Width(2) 191 | } 192 | if col == 1 { 193 | style = style.Width(48) 194 | } 195 | if col == 2 { 196 | style = style.Width(10) 197 | } 198 | if col == 3 { 199 | style = style.Width(10) 200 | } 201 | if col == 4 { 202 | style = style.Width(10) 203 | } 204 | return style.AlignHorizontal(lipgloss.Center) 205 | }) 206 | 207 | fmt.Println(t) 208 | return nil 209 | } 210 | 211 | func ShowAllPipelines() error { 212 | pipelines, err := ListPipelines(context.Background()) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | return listPipeline(pipelines) 218 | } 219 | -------------------------------------------------------------------------------- /pippy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nixmade/pippy/ca1c6377a20b57582b61675595b84ca1af549dd2/pippy.png -------------------------------------------------------------------------------- /pippy_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nixmade/pippy/ca1c6377a20b57582b61675595b84ca1af549dd2/pippy_flow.png -------------------------------------------------------------------------------- /repos/repo.go: -------------------------------------------------------------------------------- 1 | package repos 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/nixmade/pippy/github" 10 | 11 | "github.com/urfave/cli/v3" 12 | ) 13 | 14 | func Command() *cli.Command { 15 | return &cli.Command{ 16 | Name: "repo", 17 | Usage: "repo management", 18 | Commands: []*cli.Command{ 19 | { 20 | Name: "list", 21 | Usage: "list repos in a org", 22 | Action: func(ctx context.Context, c *cli.Command) error { 23 | if err := RunListRepos(github.DefaultClient, c.String("type")); err != nil { 24 | fmt.Printf("%v\n", err) 25 | return err 26 | } 27 | return nil 28 | }, 29 | Flags: []cli.Flag{ 30 | &cli.StringFlag{ 31 | Name: "type", 32 | Usage: "repo type all, owner, public, private, member", 33 | Value: "owner", 34 | Required: false, 35 | Action: func(ctx context.Context, c *cli.Command, v string) error { 36 | validValues := []string{"all", "owner", "public", "private", "member"} 37 | if slices.Contains(validValues, v) { 38 | return nil 39 | } 40 | return fmt.Errorf("please provide a valid value in %s", strings.Join(validValues, ",")) 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /repos/repo_ui.go: -------------------------------------------------------------------------------- 1 | package repos 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/nixmade/pippy/github" 10 | ) 11 | 12 | var ( 13 | docStyle = lipgloss.NewStyle().Margin(1, 2) 14 | ) 15 | 16 | type model struct { 17 | list list.Model 18 | } 19 | 20 | func (m model) Init() tea.Cmd { 21 | return nil 22 | } 23 | 24 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 25 | switch msg := msg.(type) { 26 | case tea.KeyMsg: 27 | if msg.String() == "ctrl+c" { 28 | return m, tea.Quit 29 | } 30 | case tea.WindowSizeMsg: 31 | h, v := docStyle.GetFrameSize() 32 | m.list.SetSize(msg.Width-h, msg.Height-v) 33 | } 34 | 35 | var cmd tea.Cmd 36 | m.list, cmd = m.list.Update(msg) 37 | return m, cmd 38 | } 39 | 40 | func (m model) View() string { 41 | return docStyle.Render(m.list.View()) 42 | } 43 | 44 | func Run(items []list.Item) error { 45 | m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)} 46 | m.list.Title = "Repos" 47 | 48 | p := tea.NewProgram(m, tea.WithAltScreen()) 49 | 50 | if _, err := p.Run(); err != nil { 51 | fmt.Println("Error running program:", err) 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func RunListRepos(c github.Client, repoType string) error { 59 | repoItems, err := c.ListRepos(repoType) 60 | if err != nil { 61 | return err 62 | } 63 | var listItems []list.Item 64 | for _, repoItem := range repoItems { 65 | listItems = append(listItems, repoItem) 66 | } 67 | return Run(listItems) 68 | } 69 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path" 8 | 9 | "github.com/nixmade/orchestrator/store" 10 | ) 11 | 12 | var ( 13 | HomeDir string = "" 14 | defaultStore store.Store 15 | ErrKeyNotFound = store.ErrKeyNotFound 16 | TABLE_NAME = "pippy" 17 | PUBLIC_SCHEMA = store.PUBLIC_SCHEMA 18 | ) 19 | 20 | type contextKey struct { 21 | name string 22 | } 23 | 24 | func (k *contextKey) String() string { 25 | return "context value " + k.name 26 | } 27 | 28 | var ( 29 | DatabaseSchemaCtx = &contextKey{"DatabaseSchema"} 30 | DatabaseTableCtx = &contextKey{"DatabaseTable"} 31 | ) 32 | 33 | func GetHomeDir() (string, error) { 34 | if HomeDir != "" { 35 | return HomeDir, nil 36 | } 37 | userHomeDir, err := os.UserHomeDir() 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | HomeDir = userHomeDir 43 | return HomeDir, nil 44 | } 45 | 46 | func Get(ctx context.Context) (store.Store, error) { 47 | if defaultStore != nil { 48 | return defaultStore, nil 49 | } 50 | 51 | if os.Getenv("DATABASE_URL") != "" { 52 | schemaName := store.PUBLIC_SCHEMA 53 | tableName := TABLE_NAME 54 | schema := ctx.Value(DatabaseSchemaCtx) 55 | if schema != nil { 56 | schemaName = schema.(string) 57 | } 58 | table := ctx.Value(DatabaseTableCtx) 59 | if table != nil { 60 | tableName = table.(string) 61 | } 62 | return store.NewPgxStore(os.Getenv("DATABASE_URL"), schemaName, tableName) 63 | } 64 | 65 | userHomeDir, err := GetHomeDir() 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | dbDir := path.Join(userHomeDir, ".pippy", "db", "pippy") 71 | if err := os.MkdirAll(dbDir, os.ModePerm); err != nil { 72 | return nil, err 73 | } 74 | dbStore, err := store.NewBadgerDBStore(dbDir, "") 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to create store %v", err) 77 | } 78 | 79 | return dbStore, nil 80 | } 81 | 82 | func Close(dbStore store.Store) error { 83 | if defaultStore != nil { 84 | return nil 85 | } 86 | return dbStore.Close() 87 | } 88 | -------------------------------------------------------------------------------- /users/auth.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/url" 9 | "os/exec" 10 | "runtime" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/nixmade/pippy/helpers" 16 | 17 | "github.com/nixmade/pippy/store" 18 | 19 | "github.com/urfave/cli/v3" 20 | ) 21 | 22 | var ClientID string = "46ca5443da5014f4f00f" 23 | 24 | const ( 25 | Settings = "settings:pippy" 26 | Scope = "repo workflow user:email read:org read:user" 27 | GrantType = "urn:ietf:params:oauth:grant-type:device_code" 28 | ) 29 | 30 | func Command() *cli.Command { 31 | return &cli.Command{ 32 | Name: "user", 33 | Usage: "user management", 34 | Commands: []*cli.Command{ 35 | { 36 | Name: "login", 37 | Usage: "login user", 38 | Action: func(ctx context.Context, c *cli.Command) error { 39 | if _, err := LoginUser(); err != nil { 40 | fmt.Printf("%v\n", err) 41 | return err 42 | } 43 | return nil 44 | }, 45 | }, 46 | }, 47 | } 48 | } 49 | 50 | // userStore caches accesstoken and apikey in ~/.pippy/db 51 | type UserStore struct { 52 | AccessToken string `json:"access_token"` 53 | ExpiresIn int64 `json:"expires_in"` 54 | RefreshToken string `json:"refresh_token"` 55 | RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` 56 | RefreshTime int64 `json:"refresh_time"` 57 | GithubUser githubUser `json:"user"` 58 | } 59 | 60 | func CacheTokens(cacheStore *UserStore) error { 61 | dbStore, err := store.Get(context.Background()) 62 | if err != nil { 63 | return err 64 | } 65 | defer store.Close(dbStore) 66 | 67 | if cacheStore.GithubUser.Login == "" { 68 | user, err := GithubUser(cacheStore.AccessToken) 69 | if err != nil { 70 | return err 71 | } 72 | cacheStore.GithubUser = *user 73 | } 74 | 75 | return dbStore.SaveJSON(Settings, cacheStore) 76 | } 77 | 78 | func GetCachedTokens() (*UserStore, error) { 79 | dbStore, err := store.Get(context.Background()) 80 | if err != nil { 81 | return nil, err 82 | } 83 | defer store.Close(dbStore) 84 | 85 | cacheStore := &UserStore{} 86 | if err := dbStore.LoadJSON(Settings, cacheStore); err != nil { 87 | if errors.Is(err, store.ErrKeyNotFound) { 88 | return nil, nil 89 | } 90 | return nil, err 91 | } 92 | 93 | return cacheStore, nil 94 | } 95 | 96 | func GetCachedAccessToken() (string, error) { 97 | cachedStore, err := LoginUser() 98 | if err != nil { 99 | return "", err 100 | } 101 | 102 | return cachedStore.AccessToken, nil 103 | } 104 | 105 | // LoginUser logs user into pippy and generates api key for usage 106 | // Apikey is typically valid for predetermined time, key is automatically refreshed by client 107 | // if signup is true, signs up user automatically 108 | func LoginUser() (*UserStore, error) { 109 | cachedStore, err := GetCachedTokens() 110 | if err != nil { 111 | return nil, err 112 | } 113 | if cachedStore != nil && cachedStore.AccessToken != "" { 114 | if cachedStore.ExpiresIn > 0 { 115 | currentTime := time.Now().UTC().Unix() 116 | if currentTime > (cachedStore.RefreshTime + cachedStore.ExpiresIn) { 117 | userStore, err := RefreshAccessToken(ClientID, "", cachedStore.RefreshToken) 118 | if err != nil { 119 | return nil, err 120 | } 121 | return userStore, CacheTokens(userStore) 122 | } 123 | } 124 | return cachedStore, nil 125 | } 126 | 127 | resp, err := helpers.HttpPost("https://github.com/login/device/code", fmt.Sprintf("client_id=%s&scope=%s", ClientID, Scope)) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | user_store, err := getAccessToken(string(resp)) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | if err := CacheTokens(user_store); err != nil { 138 | return nil, err 139 | } 140 | 141 | return user_store, nil 142 | } 143 | 144 | func getAccessToken(resp string) (*UserStore, error) { 145 | values, err := url.ParseQuery(resp) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | verification_url, err := url.QueryUnescape(values.Get("verification_uri")) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | expires, err := strconv.ParseInt(values.Get("expires_in"), 10, 32) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | interval, err := strconv.ParseInt(values.Get("interval"), 10, 32) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | fmt.Printf("Please enter user verification code %s at %s\n", values.Get("user_code"), verification_url) 166 | 167 | openbrowser(verification_url) 168 | 169 | params := url.Values{} 170 | params.Set("client_id", ClientID) 171 | params.Set("device_code", values.Get("device_code")) 172 | params.Set("grant_type", GrantType) 173 | 174 | start := time.Now().UTC() 175 | for int64(time.Since(start).Seconds()) < expires { 176 | resp, err := helpers.HttpPost("https://github.com/login/oauth/access_token", params.Encode()) 177 | if err != nil { 178 | time.Sleep(time.Duration(interval) * time.Second) 179 | continue 180 | } 181 | 182 | token, err := url.ParseQuery(resp) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | if token.Get("access_token") == "" { 188 | error_code := token.Get("error") 189 | if strings.EqualFold(error_code, "slow_down") { 190 | interval, err = strconv.ParseInt(token.Get("interval"), 10, 32) 191 | if err != nil { 192 | return nil, err 193 | } 194 | } else if !strings.EqualFold(error_code, "authorization_pending") { 195 | fmt.Printf("terminal error: %s\n", token.Get("error_description")) 196 | break 197 | } 198 | 199 | time.Sleep(time.Duration(interval) * time.Second) 200 | continue 201 | } 202 | 203 | access_token := token.Get("access_token") 204 | refresh_token := token.Get("refresh_token") 205 | expires_in_token := token.Get("expires_in") 206 | var expires_in int64 = 0 207 | 208 | if expires_in_token != "" { 209 | expires_in, err = strconv.ParseInt(expires_in_token, 10, 64) 210 | if err != nil { 211 | return nil, err 212 | } 213 | } 214 | 215 | refresh_token_expires_in_token := token.Get("refresh_token_expires_in") 216 | var refresh_token_expires_in int64 = 0 217 | 218 | if refresh_token_expires_in_token != "" { 219 | refresh_token_expires_in, err = strconv.ParseInt(refresh_token_expires_in_token, 10, 64) 220 | if err != nil { 221 | return nil, err 222 | } 223 | } 224 | 225 | return &UserStore{ 226 | AccessToken: access_token, 227 | RefreshToken: refresh_token, 228 | ExpiresIn: expires_in, 229 | RefreshTokenExpiresIn: refresh_token_expires_in, 230 | RefreshTime: time.Now().UTC().Unix(), 231 | }, nil 232 | } 233 | 234 | return nil, fmt.Errorf("failed to get access token, user did not authorize within %d seconds", expires) 235 | } 236 | 237 | func RefreshAccessToken(clientID, clientSecret, refreshToken string) (*UserStore, error) { 238 | params := url.Values{} 239 | params.Set("client_id", clientID) 240 | if clientSecret != "" { 241 | params.Set("client_secret", clientSecret) 242 | } 243 | params.Set("refresh_token", refreshToken) 244 | params.Set("grant_type", "refresh_token") 245 | 246 | resp, err := helpers.HttpPost("https://github.com/login/oauth/access_token", params.Encode()) 247 | if err != nil { 248 | return nil, err 249 | } 250 | token, err := url.ParseQuery(resp) 251 | if err != nil { 252 | return nil, err 253 | } 254 | 255 | refreshError := token.Get("error") 256 | if refreshError != "" { 257 | refreshErrorDescription := token.Get("error_description") 258 | return nil, fmt.Errorf("failed to refresh token %s: %s", refreshError, refreshErrorDescription) 259 | } 260 | 261 | access_token := token.Get("access_token") 262 | refresh_token := token.Get("refresh_token") 263 | expires_in, err := strconv.ParseInt(token.Get("expires_in"), 10, 64) 264 | if err != nil { 265 | return nil, err 266 | } 267 | 268 | refresh_token_expires_in, err := strconv.ParseInt(token.Get("refresh_token_expires_in"), 10, 64) 269 | if err != nil { 270 | return nil, err 271 | } 272 | 273 | userStore := &UserStore{ 274 | AccessToken: access_token, 275 | RefreshToken: refresh_token, 276 | ExpiresIn: expires_in, 277 | RefreshTokenExpiresIn: refresh_token_expires_in, 278 | RefreshTime: time.Now().UTC().Unix(), 279 | } 280 | return userStore, nil 281 | } 282 | 283 | func openbrowser(url string) { 284 | var err error 285 | 286 | switch runtime.GOOS { 287 | case "linux": 288 | err = exec.Command("xdg-open", url).Start() 289 | case "windows": 290 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 291 | case "darwin": 292 | err = exec.Command("open", url).Start() 293 | default: 294 | err = fmt.Errorf("unsupported platform") 295 | } 296 | if err != nil { 297 | log.Fatal(err) 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /users/users.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | type contextKey struct { 13 | name string 14 | } 15 | 16 | func (k *contextKey) String() string { 17 | return "context value " + k.name 18 | } 19 | 20 | var ( 21 | NameCtx = &contextKey{"UserName"} 22 | EmailCtx = &contextKey{"UserEmail"} 23 | ) 24 | 25 | type githubUser struct { 26 | Login string `json:"login"` 27 | Id uint64 `json:"id"` 28 | Name string `json:"name"` 29 | Email string `json:"email"` 30 | AvatarUrl string `json:"avatar_url"` 31 | } 32 | 33 | type githubEmail struct { 34 | Email string `json:"email"` 35 | Primary bool `json:"primary"` 36 | } 37 | 38 | type HttpError struct { 39 | Message string `json:"message"` 40 | } 41 | 42 | // tokenFromHeader tries to retreive the token string from the 43 | // "Authorization" request header: "Authorization: Token T". 44 | // func tokenFromHeader(r *http.Request) string { 45 | // // Get token from authorization header. 46 | // bearer := r.Header.Get("Authorization") 47 | // if len(bearer) > 6 && strings.ToUpper(bearer[0:5]) == "TOKEN" { 48 | // return bearer[6:] 49 | // } 50 | // return "" 51 | // } 52 | 53 | func defaultTransport() http.RoundTripper { 54 | return &http.Transport{ 55 | Proxy: http.ProxyFromEnvironment, 56 | DialContext: (&net.Dialer{ 57 | Timeout: 30 * time.Second, 58 | KeepAlive: 30 * time.Second, 59 | }).DialContext, 60 | TLSClientConfig: &tls.Config{ 61 | MinVersion: tls.VersionTLS12, 62 | CipherSuites: []uint16{ 63 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 64 | }, 65 | }, 66 | ForceAttemptHTTP2: true, 67 | MaxIdleConns: 100, 68 | IdleConnTimeout: 90 * time.Second, 69 | TLSHandshakeTimeout: 30 * time.Second, 70 | ExpectContinueTimeout: 1 * time.Second, 71 | } 72 | } 73 | 74 | func errorMessage(url string, resp *http.Response) error { 75 | var httpError HttpError 76 | if resp.Body != nil { 77 | if err := json.NewDecoder(resp.Body).Decode(&httpError); err != nil { 78 | return fmt.Errorf("%s returned %d, error: %s", url, resp.StatusCode, resp.Status) 79 | } 80 | } 81 | return fmt.Errorf("%s returned %d, details: %s", url, resp.StatusCode, httpError.Message) 82 | } 83 | 84 | func GetJSON(url, token string, value interface{}) error { 85 | req, err := http.NewRequest("GET", url, nil) 86 | if err != nil { 87 | return err 88 | } 89 | req.Header.Add("Content-Type", "application/json") 90 | req.Header.Add("Authorization", token) 91 | req.Close = true 92 | http.DefaultClient.Transport = defaultTransport() 93 | defer http.DefaultClient.CloseIdleConnections() 94 | resp, err := http.DefaultClient.Do(req) 95 | if err != nil { 96 | return err 97 | } 98 | defer resp.Body.Close() 99 | 100 | if resp.StatusCode != http.StatusOK { 101 | return errorMessage(url, resp) 102 | } 103 | 104 | if err := json.NewDecoder(resp.Body).Decode(value); err != nil { 105 | return err 106 | } 107 | 108 | return err 109 | } 110 | 111 | func GithubUser(accessToken string) (*githubUser, error) { 112 | var user githubUser 113 | err := GetJSON("https://api.github.com/user", fmt.Sprintf("token %s", accessToken), &user) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | if user.Email == "" { 119 | email, err := GithubPrimaryEmail(accessToken) 120 | if err != nil { 121 | return nil, err 122 | } 123 | user.Email = email 124 | } 125 | 126 | return &user, nil 127 | } 128 | 129 | func GithubPrimaryEmail(accessToken string) (string, error) { 130 | var emails []*githubEmail 131 | err := GetJSON("https://api.github.com/user/emails", fmt.Sprintf("token %s", accessToken), &emails) 132 | if err != nil { 133 | return "", err 134 | } 135 | 136 | for _, res := range emails { 137 | if res.Primary { 138 | return res.Email, nil 139 | } 140 | } 141 | 142 | return "", nil 143 | } 144 | -------------------------------------------------------------------------------- /workflows/workflow.go: -------------------------------------------------------------------------------- 1 | package workflows 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/nixmade/pippy/github" 10 | 11 | "github.com/charmbracelet/bubbles/list" 12 | "github.com/charmbracelet/huh" 13 | "github.com/urfave/cli/v3" 14 | ) 15 | 16 | func Command() *cli.Command { 17 | return &cli.Command{ 18 | Name: "workflow", 19 | Usage: "workflow management", 20 | Commands: []*cli.Command{ 21 | { 22 | Name: "list", 23 | Usage: "list workflows for a repo", 24 | Action: func(ctx context.Context, c *cli.Command) error { 25 | if err := RunListWorkflows(github.DefaultClient, c.String("type")); err != nil { 26 | fmt.Printf("%v\n", err) 27 | return err 28 | } 29 | return nil 30 | }, 31 | Flags: []cli.Flag{ 32 | &cli.StringFlag{ 33 | Name: "type", 34 | Usage: "repo type all, owner, public, private, member", 35 | Required: false, 36 | Value: "owner", 37 | Action: func(ctx context.Context, c *cli.Command, v string) error { 38 | validValues := []string{"all", "owner", "public", "private", "member"} 39 | if slices.Contains(validValues, v) { 40 | return nil 41 | } 42 | return fmt.Errorf("please provide a valid value in %s", strings.Join(validValues, ",")) 43 | }, 44 | }, 45 | }, 46 | }, 47 | { 48 | Name: "validate", 49 | Usage: "checks if the repo has correct configuration for pippy to function correctly", 50 | Action: func(ctx context.Context, c *cli.Command) error { 51 | if err := RunValidateRepoWorkflows(github.DefaultClient, c.String("type")); err != nil { 52 | fmt.Printf("%v\n", err) 53 | return err 54 | } 55 | return nil 56 | }, 57 | Flags: []cli.Flag{ 58 | &cli.StringFlag{ 59 | Name: "type", 60 | Usage: "repo type all, owner, public, private, member", 61 | Required: false, 62 | Value: "owner", 63 | Action: func(ctx context.Context, c *cli.Command, v string) error { 64 | validValues := []string{"all", "owner", "public", "private", "member"} 65 | if slices.Contains(validValues, v) { 66 | return nil 67 | } 68 | return fmt.Errorf("please provide a valid value in %s", strings.Join(validValues, ",")) 69 | }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | } 75 | } 76 | 77 | func RunListWorkflows(c github.Client, repoType string) error { 78 | var orgRepo string 79 | repos, err := GetRepos(repoType) 80 | if err != nil { 81 | return err 82 | } 83 | if err := huh.NewForm( 84 | huh.NewGroup( 85 | huh.NewSelect[string](). 86 | Options(huh.NewOptions(repos...)...). 87 | Title("Choose a repo"). 88 | Description("workflows for selected repo"). 89 | Value(&orgRepo), 90 | )).Run(); err != nil { 91 | return err 92 | } 93 | 94 | orgRepoSlice := strings.SplitN(orgRepo, "/", 2) 95 | org := orgRepoSlice[0] 96 | repo := orgRepoSlice[1] 97 | workflowItems, err := c.ListWorkflows(org, repo) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | var listItems []list.Item 103 | for _, workflowItem := range workflowItems { 104 | listItems = append(listItems, workflowItem) 105 | } 106 | 107 | return Run(org, repo, listItems) 108 | } 109 | -------------------------------------------------------------------------------- /workflows/workflow_ui.go: -------------------------------------------------------------------------------- 1 | package workflows 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/huh" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/nixmade/pippy/github" 13 | ) 14 | 15 | var ( 16 | docStyle = lipgloss.NewStyle().Margin(1, 2) 17 | currentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) 18 | doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00c468")) 19 | descriptionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E6E6E6")) 20 | failedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#c4002a")) 21 | checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") 22 | crossMark = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#f44336", Dark: "#cc0000"}).SetString("x") 23 | ) 24 | 25 | type model struct { 26 | list list.Model 27 | } 28 | 29 | func (m model) Init() tea.Cmd { 30 | return nil 31 | } 32 | 33 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 34 | switch msg := msg.(type) { 35 | case tea.KeyMsg: 36 | if msg.String() == "ctrl+c" { 37 | return m, tea.Quit 38 | } 39 | case tea.WindowSizeMsg: 40 | h, v := docStyle.GetFrameSize() 41 | m.list.SetSize(msg.Width-h, msg.Height-v) 42 | } 43 | 44 | var cmd tea.Cmd 45 | m.list, cmd = m.list.Update(msg) 46 | return m, cmd 47 | } 48 | 49 | func (m model) View() string { 50 | return docStyle.Render(m.list.View()) 51 | } 52 | 53 | func Run(org, repo string, items []list.Item) error { 54 | m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)} 55 | m.list.Title = fmt.Sprintf("Workflows for %s/%s", org, repo) 56 | 57 | p := tea.NewProgram(m, tea.WithAltScreen()) 58 | 59 | if _, err := p.Run(); err != nil { 60 | fmt.Println("Error running program:", err) 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func GetRepos(repoType string) ([]string, error) { 68 | repos, err := github.DefaultClient.ListRepos(repoType) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | var titles []string 74 | for _, repo := range repos { 75 | titles = append(titles, repo.Name) 76 | } 77 | 78 | return titles, nil 79 | } 80 | 81 | func RunValidateRepoWorkflows(c github.Client, repoType string) error { 82 | var orgRepos []string 83 | repos, err := GetRepos(repoType) 84 | if err != nil { 85 | return err 86 | } 87 | if err := huh.NewForm( 88 | huh.NewGroup( 89 | huh.NewMultiSelect[string](). 90 | Options(huh.NewOptions(repos...)...). 91 | Title("Choose single/multiple repo"). 92 | Description("use spacebar to select, workflows for selected repos will be validated next"). 93 | Value(&orgRepos), 94 | )).Run(); err != nil { 95 | return err 96 | } 97 | 98 | for _, orgRepo := range orgRepos { 99 | orgRepoSlice := strings.SplitN(orgRepo, "/", 2) 100 | org := orgRepoSlice[0] 101 | repo := orgRepoSlice[1] 102 | workflows, err := c.ListWorkflows(org, repo) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | if len(workflows) <= 0 { 108 | fmt.Println(currentStyle.Render("\nNo workflows found in repo " + orgRepo + "\n")) 109 | continue 110 | } 111 | 112 | fmt.Println(currentStyle.Render("\nValidations for repo " + orgRepo + "\n")) 113 | for _, workflow := range workflows { 114 | if workflow.Path == "" { 115 | continue 116 | } 117 | if changes, _, err := c.ValidateWorkflow(org, repo, workflow.Path); err != nil { 118 | return err 119 | } else { 120 | if len(changes) > 0 { 121 | fmt.Println(crossMark.Render() + " " + failedStyle.Render(workflow.Name) + "(" + failedStyle.Render(workflow.Path) + descriptionStyle.Render(") failed validation, make changes in corresponding sections:\n")) 122 | for i, change := range changes { 123 | fmt.Println(currentStyle.Render("#" + strconv.Itoa(i+1) + "\n")) 124 | fmt.Println(descriptionStyle.Render(strings.Replace(change, "\"", "", -1))) 125 | } 126 | } else { 127 | fmt.Println(checkMark.Render() + " " + doneStyle.Render(workflow.Name) + "(" + doneStyle.Render(workflow.Path) + ") passed validation") 128 | } 129 | } 130 | } 131 | fmt.Println(currentStyle.Render("End of Validations for repo " + orgRepo + "\n")) 132 | } 133 | 134 | return nil 135 | } 136 | --------------------------------------------------------------------------------