├── keys ├── .ssh │ └── key └── Imbere2_private_key.pem ├── .gitignore ├── pkg ├── event_monitor │ ├── event_monitor.go │ └── github_event_monitor.go ├── db │ ├── config.go │ └── pull_request.go ├── constants │ └── constants.go ├── client │ └── github.go ├── utils │ └── utils.go ├── webhook │ └── webhook.go ├── process_monitor │ └── process_monitor.go ├── deployment │ └── deployment.go └── pull_request │ ├── pull_request.go │ └── helpers.go ├── .vscode ├── settings.json └── launch.json ├── nextstep.md ├── main.go ├── README.md ├── go.mod └── go.sum /keys/.ssh/key: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /builds 2 | __debug* 3 | database/** -------------------------------------------------------------------------------- /pkg/event_monitor/event_monitor.go: -------------------------------------------------------------------------------- 1 | package event_monitor; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Badgef", 4 | "IMBERE" 5 | ] 6 | } -------------------------------------------------------------------------------- /pkg/event_monitor/github_event_monitor.go: -------------------------------------------------------------------------------- 1 | package event_monitor 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/rssb/imbere/pkg/db" 6 | ) 7 | 8 | type GitHubEventMonitor struct { 9 | } 10 | 11 | func (s *GitHubEventMonitor) Webhook(c *gin.Context) (*db.PullRequest, error) { 12 | return nil, nil 13 | } 14 | -------------------------------------------------------------------------------- /nextstep.md: -------------------------------------------------------------------------------- 1 | - [x] check if there is active deployment and reload 2 | - [x] get custom public URL for every PR 3 | - [x] update github on every progress 4 | - [x] deploy only PR with `IMBERE_DEPLOYMENT` label 5 | - [x] undeploy on PR close 6 | - [ ] clone with ssh keys 7 | - [ ] linux & mac full support 8 | - [ ] use temporary sub-domains instead of ip & port 9 | - [ ] cluster logs and store logs 10 | - [ ] UI to monitor the app 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/rssb/imbere/pkg/db" 8 | "github.com/rssb/imbere/pkg/webhook" 9 | ) 10 | 11 | func main() { 12 | db.DbInit() // 13 | 14 | router := gin.Default() 15 | 16 | r := router.Group("/api/v1") 17 | 18 | r.GET("/ping", func(c *gin.Context) { 19 | c.JSON(http.StatusOK, gin.H{ 20 | "message": "pong", 21 | }) 22 | }) 23 | 24 | r.POST("/github/webhook", webhook.HandleWebhook) 25 | 26 | router.Run() 27 | } 28 | -------------------------------------------------------------------------------- /pkg/db/config.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | _ "github.com/mattn/go-sqlite3" 5 | "gorm.io/driver/sqlite" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | const file string = "./database/imbere.db" 10 | 11 | func dbCon() *gorm.DB { 12 | db, err := gorm.Open(sqlite.Open(file), &gorm.Config{}) 13 | 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | return db 19 | } 20 | 21 | // Check database connection 22 | // and Create tables in db; 23 | func DbInit() { 24 | db := dbCon() 25 | 26 | db.AutoMigrate(&PullRequest{}) 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Main", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}", 13 | "logOutput": "dap", 14 | "showLog": true, 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 🚀 IMBERE 2 | IMBERE is a tool designed to automate the deployment of code changes from pull requests. With IMBERE, every pull request you create triggers a corresponding deployment, providing a public link for easy access and review of the deployed changes. 3 | 4 | ![image](https://github.com/user-attachments/assets/afc39c9c-3c65-4d55-8752-8486a9cb5414) 5 | 6 | 7 | ### Getting Started 8 | To get started with this project, clone the repository and install the necessary dependencies. Then, run the main.go file to start the services. For more detailed instructions, refer to the project's documentation. 9 | 10 | ### Contributing 11 | Contributions to this project are welcome. Please fork the repository and create a pull request with your changes. 12 | 13 | ### License 14 | This project is licensed under the MIT License. See the LICENSE file for more details. 15 | -------------------------------------------------------------------------------- /pkg/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const MAIN_DIR = "/Users/claranceliberi/projects/rssb/imbere/" 4 | const BUILD_DIR = MAIN_DIR + "builds/" 5 | 6 | const PM2_NAMESPACE = "IMBERE" 7 | 8 | // This is the label text that will be added to github PR if they want it to be deployed 9 | const DEPLOYMENT_LABEL = "IMBERE_DEPLOY" 10 | 11 | // IMBERE2 github app id 12 | const GITHUB_APP_ID = 903361 13 | 14 | const IP_ADDRESS = "localhost" 15 | 16 | type ProcessProgress int 17 | 18 | const ( 19 | PROCESS_PROGRESS_UNKNOWN ProcessProgress = iota 20 | PROCESS_PROGRESS_STARTED 21 | PROCESS_PROGRESS_PREPARING_DIR 22 | PROCESS_PROGRESS_PULLING_CHANGES 23 | PROCESS_PROGRESS_INSTALLING_DEPENDENCIES 24 | PROCESS_PROGRESS_BUILDING_PROJECT 25 | PROCESS_PROGRESS_DEPLOYING 26 | PROCESS_PROGRESS_COMPLETED 27 | PROCESS_PROGRESS_UN_DEPLOYING 28 | ) 29 | 30 | type ProcessOutcome int 31 | 32 | const ( 33 | PROCESS_OUTCOME_NOT_YET ProcessOutcome = iota 34 | PROCESS_OUTCOME_ONGOING 35 | PROCESS_OUTCOME_SUCCEEDED 36 | PROCESS_OUTCOME_FAILED 37 | ) 38 | 39 | var ALLOWED_EVENT_ACTIONS = map[string]bool{ 40 | "workflow_run.completed": true, 41 | "pull_request.closed": true, 42 | "pull_request.opened": true, 43 | "pull_request.reopened": true, 44 | "pull_request.labeled": true, 45 | "pull_request.unlabeled": true, 46 | } 47 | -------------------------------------------------------------------------------- /keys/Imbere2_private_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA9t360Gkowq76nBXxAbMuPWQ2nFLXIaG0imh31q5kWcmpLcQb 3 | abiUY2vjoCYJM4mgI3vKMVJ2idUbATKCuj6O0K3ouJ0NEFiyf/6VrtS4J5hT0Obq 4 | cmWd7bnWSCf8FPxjgyT8pcxLAixN74TGz1otvoBWsrtwL1U5gJlyElUVCT7JxfMq 5 | iPoiDzXhokhD9lunCt8C+VVxPHk63ALIC0yocgPZoT0D52QBlrcbqQB2S9VUVwm+ 6 | IIIRiFPZJLpRBniB4X5u9+i99aKyfMp+acf446XnMqVVHH8swAgxx7tDLDqEj95Z 7 | QE0kyUwgTeX+plgj6k6M31UnKgrnQnq2di6HJQIDAQABAoIBABqXEFPwb76AgGfd 8 | iScIuLtOFv/BgICT3VFnLTlHcXGmYf2W+OjrQ7htv4fZcPZHJaOMytuDKVdxR8Za 9 | PXB4GoPpp35zFBwloZxdvJunN+qz4ptGtAv8XAVmFemRvPo5sTJIApqNJasnQIHD 10 | CHL/IJw7UJtRyySeBmKOpJXK6kgjcAkFyrYHnM/27axb3JsuluOMdLNHgf0o5RiW 11 | Bf6yCtV6vgH5hgyiZrSu9cusvk6DuyHjioBM1NpVGsmzlPugKLcaEcAVG/DvMT4S 12 | HSAnoq8Fdl5K58oHqaBUdNdcxRziR7EdUpVUg+UFO/lKdczlyYnHhQ4sEPtACnDE 13 | ZDZyKyUCgYEA/v0IokkZMqr73lFeW+fYkJ4fTeH5asySDWFVIkQUPujwOQs/p9Zy 14 | uyzhgkino/EvmDFz3Ja5Isq9UoAjsB0UlIkPX7/1CFbROtVfZwUgUQSUCYKUl01N 15 | IcrOcMbDr9t3EbZKgmZrlsf2iToWFBYgkh4guBg1QkYLEUqAj6OqkB8CgYEA99iy 16 | sU8P8Pddv3PRdvEzxZVaoCzuGAm/IYfEEzPDLp711d7dVgpTZdpOfeZwLXG/UKs8 17 | lJ/SiZbk2wpWMhy8zVl+S+K7iG6B5Nc0JEH+VjacK2C+BCrSmpISb4VseqGMoVQL 18 | 48+BZ/Fos9EtHa9iYjJD2bidJoUp4QlkDGP2sDsCgYEAo3lVr7Vtki5Mi5tfA00A 19 | arb1GtllLjM475sXGYDL+gkc+XVcuh2iMJ31dbFeyJw4BzqtLR70UnnoJpxARQhf 20 | yVqVoK1QSqheO3nQUBvZPBfTUMWGvfmxGZkMAQFtM7FgBNr5qeA/2csZI6E5xmbp 21 | OsRV9bCPdI2zSsKY6X3kO8ECgYEAsJr2AchPmKn7YE/8AyAufu1E7Xv2kKRpUFA4 22 | 1GH/A7p7fFFZMFUdRid/5NCOQKOb6rSJ73HPCQG/w6Ei+IRnuH+7sgE3FimAX3tF 23 | iUyJGg/BylseK0QEW+YYQuU0/lEdL1v/OO6EzHdF31P/LPbrSQN+O6cEPA2JYPRb 24 | GUUl5D8CgYBLrL+fnntF1NB5MvBUcwnYiiUhj5FcydD9J69MPijalN3abEbG102u 25 | ZT7SrqjbS3Tr+DGIsjuQmCnawdpP6fvVzCdvKdjbwFbbAGMb0t+WLPXagJCyO0/8 26 | 9G64zQN65oZE/B3TOAxAO5kRSqON2aoz4GMLpaJOjzTRBdMoCwMRRw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /pkg/client/github.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/bradleyfalzon/ghinstallation" 10 | "github.com/google/go-github/github" 11 | "github.com/rssb/imbere/pkg/constants" 12 | ) 13 | 14 | type GithubClient struct { 15 | client *github.Client 16 | } 17 | 18 | func NewGithubClient(installationID int64) *GithubClient { 19 | // Shared transport to reuse TCP connections. 20 | tr := http.DefaultTransport 21 | 22 | // Wrap the shared transport for use with the app ID 1 authenticating with installation ID 99. 23 | itr, err := ghinstallation.NewKeyFromFile(tr, constants.GITHUB_APP_ID, installationID, "keys/Imbere2_private_key.pem") 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | 28 | // Use installation transport with github.com/google/go-github 29 | ghClient := github.NewClient(&http.Client{Transport: itr}) 30 | 31 | client := GithubClient{ 32 | client: ghClient, 33 | } 34 | 35 | return &client 36 | } 37 | 38 | func (gc *GithubClient) CreateComment(owner string, repo string, number int64, content string) (*int64, error) { 39 | 40 | comment := github.IssueComment{ 41 | Body: &content, 42 | } 43 | 44 | log.Printf("creating comment") 45 | 46 | prComment, _, err := gc.client.Issues.CreateComment(context.Background(), owner, repo, int(number), &comment) 47 | 48 | if err != nil { 49 | return nil, fmt.Errorf("Could not create comment on pull request %v", err) 50 | } 51 | 52 | log.Printf("Comment created with ID: %d\n", *prComment.ID) 53 | 54 | return prComment.ID, nil 55 | 56 | } 57 | 58 | func (gc *GithubClient) EditComment(id int64, owner string, repo string, content string) (*int64, error) { 59 | 60 | comment := github.IssueComment{ 61 | Body: &content, 62 | } 63 | 64 | prComment, _, err := gc.client.Issues.EditComment(context.Background(), owner, repo, id, &comment) 65 | 66 | if err != nil { 67 | return nil, fmt.Errorf("Could not create comment on pull request %v", err) 68 | } 69 | 70 | log.Printf("Comment edited with ID: %d\n", *prComment.ID) 71 | return prComment.ID, nil 72 | 73 | } 74 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rssb/imbere 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.3 6 | 7 | require ( 8 | github.com/bradleyfalzon/ghinstallation v1.1.1 9 | github.com/gin-gonic/gin v1.10.0 10 | github.com/google/go-github v17.0.0+incompatible 11 | github.com/google/go-github/v62 v62.0.0 12 | github.com/mattn/go-sqlite3 v1.14.22 13 | gorm.io/driver/sqlite v1.5.5 14 | gorm.io/gorm v1.25.10 15 | ) 16 | 17 | require ( 18 | github.com/karrick/godirwalk v1.17.0 // indirect 19 | github.com/mattn/go-runewidth v0.0.9 // indirect 20 | github.com/olekukonko/tablewriter v0.0.5 // indirect 21 | ) 22 | 23 | require ( 24 | github.com/bytedance/sonic v1.11.6 // indirect 25 | github.com/bytedance/sonic/loader v0.1.1 // indirect 26 | github.com/cloudwego/base64x v0.1.4 // indirect 27 | github.com/cloudwego/iasm v0.2.0 // indirect 28 | github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect 29 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 30 | github.com/gin-contrib/sse v0.1.0 // indirect 31 | github.com/go-playground/locales v0.14.1 // indirect 32 | github.com/go-playground/universal-translator v0.18.1 // indirect 33 | github.com/go-playground/validator/v10 v10.20.0 // indirect 34 | github.com/goccy/go-json v0.10.2 // indirect 35 | github.com/google/go-github/v29 v29.0.2 // indirect 36 | github.com/google/go-querystring v1.1.0 // indirect 37 | github.com/jinzhu/inflection v1.0.0 // indirect 38 | github.com/jinzhu/now v1.1.5 // indirect 39 | github.com/json-iterator/go v1.1.12 // indirect 40 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 41 | github.com/leodido/go-urn v1.4.0 // indirect 42 | github.com/mattn/go-isatty v0.0.20 // indirect 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 44 | github.com/modern-go/reflect2 v1.0.2 // indirect 45 | github.com/nao1215/markdown v0.4.0 46 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 47 | github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 // indirect 48 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 49 | github.com/ugorji/go/codec v1.2.12 // indirect 50 | golang.org/x/arch v0.8.0 // indirect 51 | golang.org/x/crypto v0.23.0 // indirect 52 | golang.org/x/net v0.25.0 // indirect 53 | golang.org/x/sys v0.20.0 // indirect 54 | golang.org/x/text v0.15.0 // indirect 55 | google.golang.org/protobuf v1.34.1 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "runtime/debug" 8 | 9 | "github.com/gin-gonic/gin" 10 | md "github.com/nao1215/markdown" 11 | "github.com/phayes/freeport" 12 | "github.com/rssb/imbere/pkg/constants" 13 | ) 14 | 15 | func ReturnError(c *gin.Context, message string) { 16 | c.JSON(http.StatusBadRequest, gin.H{ 17 | "message": message, 18 | }) 19 | debug.PrintStack() 20 | fmt.Println("Error occured %+v\n", message) 21 | 22 | } 23 | 24 | func GetFreePort() (int32, error) { 25 | port, err := freeport.GetFreePort() 26 | if err != nil { 27 | log.Fatal(err) 28 | return 0, err 29 | } 30 | 31 | log.Printf("port %d", port) 32 | 33 | return int32(port), nil 34 | } 35 | 36 | 37 | func ParseProgressToMD(progress constants.ProcessProgress, outcome constants.ProcessOutcome) *md.Markdown { 38 | markdown := md.NewMarkdown(nil) 39 | markdown.H3("Progress Status") 40 | 41 | progressSteps := []constants.ProcessProgress{ 42 | constants.PROCESS_PROGRESS_STARTED, 43 | constants.PROCESS_PROGRESS_PREPARING_DIR, 44 | constants.PROCESS_PROGRESS_PULLING_CHANGES, 45 | constants.PROCESS_PROGRESS_INSTALLING_DEPENDENCIES, 46 | constants.PROCESS_PROGRESS_BUILDING_PROJECT, 47 | constants.PROCESS_PROGRESS_DEPLOYING, 48 | constants.PROCESS_PROGRESS_COMPLETED, 49 | } 50 | 51 | for _, step := range progressSteps { 52 | if step == progress { 53 | switch outcome { 54 | case constants.PROCESS_OUTCOME_SUCCEEDED: 55 | markdown.PlainTextf(fmt.Sprintf("✅ %s", GetProgressStepName(step))) 56 | case constants.PROCESS_OUTCOME_FAILED: 57 | markdown.PlainTextf(fmt.Sprintf("❌ %s", GetProgressStepName(step))) 58 | case constants.PROCESS_OUTCOME_ONGOING: 59 | markdown.PlainTextf(fmt.Sprintf("⏳ %s", GetProgressStepName(step))) 60 | } 61 | } else if step < progress { 62 | markdown.PlainTextf(fmt.Sprintf("✅ %s", GetProgressStepName(step))) 63 | } else { 64 | markdown.PlainTextf(fmt.Sprintf("⚪ %s", GetProgressStepName(step))) 65 | } 66 | } 67 | 68 | return markdown 69 | } 70 | 71 | func GetProgressStepName(step constants.ProcessProgress) string { 72 | switch step { 73 | case constants.PROCESS_PROGRESS_STARTED: 74 | return "Started" 75 | case constants.PROCESS_PROGRESS_PREPARING_DIR: 76 | return "Preparing Directory" 77 | case constants.PROCESS_PROGRESS_PULLING_CHANGES: 78 | return "Pulling Changes" 79 | case constants.PROCESS_PROGRESS_INSTALLING_DEPENDENCIES: 80 | return "Installing Dependencies" 81 | case constants.PROCESS_PROGRESS_BUILDING_PROJECT: 82 | return "Building Project" 83 | case constants.PROCESS_PROGRESS_DEPLOYING: 84 | return "Deploying" 85 | case constants.PROCESS_PROGRESS_COMPLETED: 86 | return "Completed" 87 | default: 88 | return "Unknown" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pkg/webhook/webhook.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/rssb/imbere/pkg/constants" 9 | "github.com/rssb/imbere/pkg/pull_request" 10 | "github.com/rssb/imbere/pkg/utils" 11 | ) 12 | 13 | // HandleWebhook is the main entry point for handling incoming github webhooks. 14 | // It parses the payload, extracts the event type, and handles the event if it's one of the supported types. 15 | // If the event is handled, it creates a PullRequest from the payload, and then pulls the changes(which later triggers deployment). 16 | // If the event is not handled, it simply returns a 202 Accepted response. 17 | func HandleWebhook(c *gin.Context) { 18 | var payload map[string]any 19 | 20 | if err := c.ShouldBindJSON(&payload); err != nil { 21 | 22 | utils.ReturnError(c, err.Error()) 23 | } 24 | 25 | // get event type 26 | event, err := pull_request.ExtractEventType(c, payload) 27 | 28 | if err != nil { 29 | utils.ReturnError(c, err.Error()) 30 | return 31 | } 32 | 33 | nameAction := event.GetNameAction() 34 | 35 | fmt.Println(nameAction) 36 | 37 | isHandledEVentAction := constants.ALLOWED_EVENT_ACTIONS[nameAction] 38 | 39 | if isHandledEVentAction { 40 | 41 | err := pull_request.HandlePR(event, payload) 42 | 43 | if err != nil { 44 | utils.ReturnError(c, err.Error()) 45 | } 46 | 47 | } 48 | 49 | c.JSON(http.StatusAccepted, gin.H{ 50 | "message": "not yet started", 51 | }) 52 | } 53 | 54 | // PULL REQUESTS 55 | // once any accepted event is triggered on webhook, we do the following 56 | // 1. we extract information from the payload 57 | // a. if event is related to new PR or changes in PR (pull_request.open, pull_request.reopen, workflow_run.completed) or labeled deployment IMBERE_DEPLOY 58 | // a.1. we create directory for the pr or replace it if it exists 59 | // a.2. we pull latest changes from the pr 60 | // a.3. we store/update the information for the pr in db 61 | // a.4. we notify the deployment service to deploy 62 | // b. if the event is related to closed pr or unlabeled IMBERE_DEPLOY 63 | // b.1 we delete the directory that contains changes 64 | // b.2 we update information in db 65 | // b.3 we notify the deployment service to undeploy 66 | // Note: Every process is communicated to github 67 | 68 | // DEPLOYMENTS 69 | // a. ON_DEPLOY 70 | // a.1 install packages 71 | // b.2 build project 72 | // b.3 check if there was no deployment dedicated to that PR 73 | // b.4 if it existed kill existing deployment 74 | // b.5 deploy (to given deployment service) 75 | // b.6 store/update deployment information in db 76 | // b. ON_UNDEPLOY 77 | // b.1 undeploy (from given deployment service) 78 | // b.2 update deployment info in db 79 | // Note: Every process is communicated to github 80 | 81 | // communicate to github the status (probably a separate function that would communicate every step) 82 | 83 | // EVENTS 84 | // pull_request.opened OR pull_request.reopened 85 | // CREATE A RECORD IN DB WITH NECESSARY INFORMATION FOR THE PR 86 | 87 | // workflow_run.completed 88 | // UPDATE DB WITH WORKFLOW STATUS 0 or 1, meaning succeeded or not 89 | // IF PR WAS LABELED 'IMBERE_DEPLOY' , DEPLOY 90 | 91 | // pull_request.labeled 92 | // UPDATE DB LABELED TO DEPLOY 93 | // IF WORKFLOW WAS 1, DEPLOY 94 | 95 | // pull_request.unlabeled 96 | // UPDATE DB LABEL TO NOT DEPLOY 97 | 98 | // pull_request.closed 99 | // MARK PR CLOSED , NO MORE DEPLOY 100 | 101 | // DATABASE TABLE 102 | // ID, PR ID, PR_NUMBER(string), BRANCH(string), URL(string), WORKFLOW_SUCCEEDED(1,0), LABELED_TO_DEPLOY(1,0), ACTIVE(1,0), ....INFORMATION FROM PM2 103 | -------------------------------------------------------------------------------- /pkg/process_monitor/process_monitor.go: -------------------------------------------------------------------------------- 1 | package process_monitor 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os/exec" 8 | "strconv" 9 | 10 | "github.com/rssb/imbere/pkg/client" 11 | "github.com/rssb/imbere/pkg/constants" 12 | "github.com/rssb/imbere/pkg/db" 13 | "github.com/rssb/imbere/pkg/utils" 14 | ) 15 | 16 | type ProcessMonitor struct { 17 | ID int64 18 | Progress constants.ProcessProgress 19 | Status constants.ProcessOutcome 20 | Logs chan string 21 | client *client.GithubClient 22 | pr *db.PullRequest 23 | prRepo *db.PullRequestRepo 24 | } 25 | 26 | func NewProcessMonitor(pr *db.PullRequest) *ProcessMonitor { 27 | prRepo := &db.PullRequestRepo{} 28 | processMonitor := &ProcessMonitor{ 29 | ID: pr.PrID, 30 | Progress: constants.PROCESS_PROGRESS_STARTED, 31 | Status: constants.PROCESS_OUTCOME_ONGOING, 32 | Logs: make(chan string), 33 | client: client.NewGithubClient(pr.InstallationID), 34 | pr: pr, 35 | prRepo: prRepo, 36 | } 37 | 38 | processMonitor.HandleLogs() // immediately start listening to logs 39 | 40 | return processMonitor 41 | } 42 | 43 | func (p *ProcessMonitor) SetPort(port int32) { 44 | p.pr.DeploymentPort = port 45 | } 46 | 47 | func (p *ProcessMonitor) UpdateProgress(progress constants.ProcessProgress, status constants.ProcessOutcome) { 48 | p.Progress = progress 49 | p.Status = status 50 | 51 | appURL := "http://" + constants.IP_ADDRESS + ":" + strconv.Itoa(int(p.pr.DeploymentPort)) 52 | 53 | progressMarkdown := utils.ParseProgressToMD(p.Progress, p.Status) 54 | progressMarkdown.PlainText("") 55 | progressMarkdown.H2("Deployment Url") 56 | progressMarkdown.PlainText(appURL) 57 | progressMarkdown.H2("Status") 58 | 59 | isDeployed := (p.Progress == constants.PROCESS_PROGRESS_DEPLOYING && p.Status == constants.PROCESS_OUTCOME_SUCCEEDED) || (p.Progress == constants.PROCESS_PROGRESS_COMPLETED) 60 | isUnDeployed := p.Progress == constants.PROCESS_PROGRESS_UN_DEPLOYING && p.Status == constants.PROCESS_OUTCOME_SUCCEEDED 61 | 62 | if isDeployed { 63 | progressMarkdown.GreenBadgef("Deployed") 64 | } else if p.Status == constants.PROCESS_OUTCOME_FAILED { 65 | progressMarkdown.RedBadgef("Failed") 66 | } else if isUnDeployed { 67 | progressMarkdown.YellowBadgef("Undeployed") 68 | } else { 69 | progressMarkdown.YellowBadgef("Deploying") 70 | } 71 | 72 | owner := p.pr.OwnerName 73 | repo := p.pr.RepoName 74 | prNumber := p.pr.PrNumber 75 | commentId := p.pr.CommentID 76 | 77 | log.Printf("CommentId: %d, Owner: %s, Repo: %s, PR Number: %d, Comment: %s\n", commentId, owner, repo, prNumber, progressMarkdown.String()) 78 | 79 | var err error 80 | var id *int64 81 | if commentId == 0 { 82 | id, err = p.client.CreateComment(owner, repo, prNumber, progressMarkdown.String()) 83 | pullRequest := p.pr 84 | 85 | pullRequest.CommentID = *id 86 | p.prRepo.Save(pullRequest) 87 | } else { 88 | id, err = p.client.EditComment(commentId, owner, repo, progressMarkdown.String()) 89 | } 90 | 91 | log.Printf("Id was created %d, or Error %s", *id, err) 92 | 93 | fmt.Printf("Process ID: %d, Progress: %d, Status: %d\n", p.ID, p.Progress, p.Status) 94 | // To communicate the status to github 95 | } 96 | 97 | func (p *ProcessMonitor) AddLog(log string) { 98 | p.Logs <- log 99 | } 100 | 101 | func (p *ProcessMonitor) HandleLogs() { 102 | go func() { 103 | for log := range p.Logs { 104 | fmt.Printf("Process ID: %d, Log: %s\n", p.ID, log) 105 | // just in case I might want to save logs or display them 106 | } 107 | }() 108 | } 109 | 110 | func (p *ProcessMonitor) ListenToCmd(cmd *exec.Cmd) { 111 | stdout, _ := cmd.StdoutPipe() 112 | stderr, _ := cmd.StderrPipe() 113 | 114 | go func() { 115 | scanner := bufio.NewScanner(stdout) 116 | for scanner.Scan() { 117 | p.AddLog(scanner.Text()) 118 | } 119 | }() 120 | 121 | go func() { 122 | scanner := bufio.NewScanner(stderr) 123 | for scanner.Scan() { 124 | p.AddLog(scanner.Text()) 125 | } 126 | }() 127 | 128 | } 129 | -------------------------------------------------------------------------------- /pkg/db/pull_request.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type PullRequestRepo struct { 10 | db *gorm.DB 11 | } 12 | 13 | type PullRequest struct { 14 | gorm.Model 15 | PrID int64 `gorm:"type:bigint;not null"` 16 | PrNumber int64 `gorm:"type:bigint;not null"` 17 | BranchName string `gorm:"type:text;not null"` 18 | PrUrl string `gorm:"type:text;not null"` 19 | RepoName string `gorm:"type:text;not null"` 20 | RepoAddress string `gorm:"type:text;not null"` 21 | SSHAddress string `gorm:"type:text;not null"` 22 | InstallationID int64 `gorm:"type:bigint;not null"` 23 | OwnerName string `gorm:"type:text;not null"` 24 | OwnerID int64 `gorm:"type:bigint;not null"` 25 | CommentID int64 `gorm:"type:bigint;not null;default:0"` 26 | WorkflowSucceeded bool `gorm:"type:bool;not null;default:false"` // did workflow succeed from github 27 | LabeledToDeploy bool `gorm:"type:bool;not null;default:false"` // is PR labeled to be deployed on github 28 | Active bool `gorm:"type:bool;not null;default:false"` // active pull request 29 | IsDeploying bool `gorm:"type:bool;not null;default:false"` // is deploying 30 | Deployed bool `gorm:"type:bool;not null;default:false"` // is deployed (accessible over internet) 31 | DeploymentPort int32 `gorm:"type:bigint"` // deployment service port 32 | } 33 | 34 | func (pr *PullRequest) GetPrId() string { 35 | return fmt.Sprintf("%d", int(pr.PrID)) 36 | } 37 | 38 | func (pr *PullRequest) GetPrNumber() string { 39 | return fmt.Sprintf("%d", int(pr.PrNumber)) 40 | } 41 | 42 | func (pr *PullRequest) GetDir() string { 43 | return pr.RepoName + "/" + pr.BranchName + "_" + pr.GetPrNumber() 44 | } 45 | 46 | func (repo *PullRequestRepo) prepareDbConnection() { 47 | repo.db = dbCon() 48 | } 49 | func (repo *PullRequestRepo) Save(pr *PullRequest) error { 50 | repo.prepareDbConnection() 51 | 52 | existing, err := repo.GetByPrID(pr.PrID) 53 | 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if existing == nil { 59 | result := repo.db.Create(pr) 60 | if result.Error != nil { 61 | return result.Error 62 | } 63 | } else { 64 | result := repo.db.Model(existing).Updates(map[string]interface{}{ 65 | "PrNumber": pr.PrNumber, 66 | "BranchName": pr.BranchName, 67 | "PrUrl": pr.PrUrl, 68 | "RepoName": pr.RepoName, 69 | "RepoAddress": pr.RepoAddress, 70 | "SSHAddress": pr.SSHAddress, 71 | "InstallationID": pr.InstallationID, 72 | "WorkflowSucceeded": pr.WorkflowSucceeded, 73 | "LabeledToDeploy": pr.LabeledToDeploy, 74 | "Active": pr.Active, 75 | "Deployed": pr.Deployed, 76 | "DeploymentPort": pr.DeploymentPort, 77 | "IsDeploying": pr.IsDeploying, 78 | "OwnerName": pr.OwnerName, 79 | "OwnerID": pr.OwnerID, 80 | "CommentID": pr.CommentID, 81 | }) 82 | 83 | if result.Error != nil { 84 | return result.Error 85 | } 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (repo *PullRequestRepo) GetByPrID(prId int64) (*PullRequest, error) { 92 | repo.prepareDbConnection() 93 | 94 | var pr PullRequest 95 | 96 | result := repo.db.Where(&PullRequest{PrID: prId}).First(&pr) 97 | 98 | if result.Error != nil { 99 | if result.Error == gorm.ErrRecordNotFound { 100 | return nil, nil 101 | } 102 | 103 | return nil, result.Error 104 | } 105 | 106 | return &pr, nil 107 | } 108 | 109 | func (repo *PullRequestRepo) Deploy(prId int64, port int32) (*PullRequest, error) { 110 | 111 | pr, err := repo.GetByPrID(prId) 112 | 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | pr.Deployed = true 118 | pr.IsDeploying = false 119 | pr.DeploymentPort = port 120 | 121 | err = repo.Save(pr) 122 | 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | return pr, nil 128 | } 129 | 130 | func (repo *PullRequestRepo) UnDeploy(prId int64) (*PullRequest, error) { 131 | 132 | pr, err := repo.GetByPrID(prId) 133 | 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | pr.Deployed = false 139 | pr.IsDeploying = false 140 | pr.DeploymentPort = 0 141 | 142 | err = repo.Save(pr) 143 | 144 | if err != nil { 145 | return nil, err 146 | } 147 | 148 | return pr, nil 149 | } 150 | -------------------------------------------------------------------------------- /pkg/deployment/deployment.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strconv" 8 | 9 | "github.com/rssb/imbere/pkg/constants" 10 | "github.com/rssb/imbere/pkg/db" 11 | "github.com/rssb/imbere/pkg/process_monitor" 12 | "github.com/rssb/imbere/pkg/utils" 13 | ) 14 | 15 | type DeploymentService struct { 16 | pr *db.PullRequest 17 | prRepo db.PullRequestRepo 18 | monitor *process_monitor.ProcessMonitor 19 | } 20 | 21 | func NewDeploymentService(pr *db.PullRequest, monitor *process_monitor.ProcessMonitor) *DeploymentService { 22 | return &DeploymentService{ 23 | pr: pr, 24 | monitor: monitor, 25 | } 26 | } 27 | 28 | func (service *DeploymentService) WorkingDirectory() string { 29 | return constants.BUILD_DIR + service.pr.GetDir() 30 | } 31 | func (service *DeploymentService) InstallDependencies() error { 32 | cmd := exec.Command("ni") 33 | cmd.Dir = service.WorkingDirectory() 34 | 35 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_INSTALLING_DEPENDENCIES, constants.PROCESS_OUTCOME_ONGOING) 36 | service.log("Started Installing Dependencies") 37 | 38 | service.monitor.ListenToCmd(cmd) 39 | 40 | if err := cmd.Start(); err != nil { 41 | service.log(fmt.Sprintf("install command failed with %s in %s \n", err, service.WorkingDirectory())) 42 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_INSTALLING_DEPENDENCIES, constants.PROCESS_OUTCOME_FAILED) 43 | return err 44 | } 45 | 46 | if err := cmd.Wait(); err != nil { 47 | service.log(fmt.Sprintf("install command failed with %s in %s \n", err, service.WorkingDirectory())) 48 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_INSTALLING_DEPENDENCIES, constants.PROCESS_OUTCOME_FAILED) 49 | return err 50 | } 51 | 52 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_INSTALLING_DEPENDENCIES, constants.PROCESS_OUTCOME_SUCCEEDED) 53 | service.log("Finished Installing Dependencies") 54 | 55 | return nil 56 | } 57 | 58 | func (service *DeploymentService) Build() error { 59 | cmd := exec.Command("nr", "build") 60 | cmd.Dir = service.WorkingDirectory() 61 | 62 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_BUILDING_PROJECT, constants.PROCESS_OUTCOME_ONGOING) 63 | service.log("Started Building") 64 | 65 | service.monitor.ListenToCmd(cmd) 66 | 67 | if err := cmd.Start(); err != nil { 68 | service.log(fmt.Sprintf("build command failed with %s in %s \n", err, service.WorkingDirectory())) 69 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_BUILDING_PROJECT, constants.PROCESS_OUTCOME_FAILED) 70 | return err 71 | } 72 | 73 | if err := cmd.Wait(); err != nil { 74 | service.log(fmt.Sprintf("build command failed with %s in %s \n", err, service.WorkingDirectory())) 75 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_BUILDING_PROJECT, constants.PROCESS_OUTCOME_FAILED) 76 | return err 77 | } 78 | 79 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_BUILDING_PROJECT, constants.PROCESS_OUTCOME_SUCCEEDED) 80 | service.log("Finished Building") 81 | 82 | return nil 83 | } 84 | 85 | func (service *DeploymentService) Deploy() error { 86 | var port int32 87 | 88 | if service.pr.Deployed { 89 | port = service.pr.DeploymentPort 90 | } else { 91 | var portErr error 92 | port, portErr = utils.GetFreePort() 93 | 94 | if portErr != nil { 95 | service.log(fmt.Sprintf("deploy failed - failed to get port %s \n", portErr)) 96 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_DEPLOYING, constants.PROCESS_OUTCOME_FAILED) 97 | return portErr 98 | } 99 | 100 | } 101 | 102 | err := service.deployToPM2(port) 103 | 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // update db record , indicating that the pr is currently deployed 109 | pr, deployErr := service.prRepo.Deploy(service.pr.PrID, port) 110 | // Assign the latest port to the new pull request. This update will be reflected across all instances, ensuring that external clients receive the most recent port information. 111 | service.pr = pr 112 | 113 | if deployErr != nil { 114 | service.log(fmt.Sprintf("saving deployment status failed with %s in %s \n", deployErr, service.WorkingDirectory())) 115 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_DEPLOYING, constants.PROCESS_OUTCOME_FAILED) 116 | return deployErr 117 | } 118 | 119 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_COMPLETED, constants.PROCESS_OUTCOME_SUCCEEDED) 120 | service.log("Finished Deploying") 121 | 122 | return nil 123 | } 124 | 125 | func (service *DeploymentService) deployToPM2(port int32) error { 126 | var cmd *exec.Cmd 127 | 128 | // If there's an active deployment, we restart it. This approach ensures 129 | // that we only have a single instance of the app running, even when there 130 | // are changes to the pull request. 131 | if service.pr.Deployed { 132 | cmd = exec.Command("sh", "-c", "pm2 restart "+service.pr.GetPrId()) 133 | } else { 134 | cmd = exec.Command("sh", "-c", "pm2 start 'nr start' --name "+service.pr.GetPrId()+" --namespace "+constants.PM2_NAMESPACE) 135 | } 136 | 137 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_DEPLOYING, constants.PROCESS_OUTCOME_ONGOING) 138 | service.log("Started Deploying") 139 | 140 | cmd.Dir = service.WorkingDirectory() 141 | 142 | service.pr.DeploymentPort = port 143 | service.monitor.SetPort(port) 144 | cmd.Env = append(os.Environ(), "PORT="+strconv.Itoa(int(port))) 145 | 146 | service.monitor.ListenToCmd(cmd) 147 | 148 | if startErr := cmd.Start(); startErr != nil { 149 | service.log(fmt.Sprintf("start deploy command failed with %s in %s \n", startErr, service.WorkingDirectory())) 150 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_DEPLOYING, constants.PROCESS_OUTCOME_FAILED) 151 | return startErr 152 | } 153 | 154 | if waitErr := cmd.Wait(); waitErr != nil { 155 | service.log(fmt.Sprintf("wait deploy command failed with %s in %s \n", waitErr, service.WorkingDirectory())) 156 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_DEPLOYING, constants.PROCESS_OUTCOME_FAILED) 157 | return waitErr 158 | } 159 | 160 | return nil 161 | } 162 | 163 | func (service *DeploymentService) UnDeploy() error { 164 | err := service.unDeployFromPM2() 165 | 166 | if err != nil { 167 | return err 168 | } 169 | 170 | pr, deployErr := service.prRepo.UnDeploy(service.pr.PrID) 171 | 172 | if deployErr != nil { 173 | err := fmt.Sprintf("error while updating deployment record in db : %s", deployErr) 174 | service.log(err) 175 | return fmt.Errorf(err) 176 | } 177 | 178 | service.pr = pr 179 | service.log(fmt.Sprintf("successful undeployed pr ID: %d", pr.PrID)) 180 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_UN_DEPLOYING, constants.PROCESS_OUTCOME_SUCCEEDED) 181 | 182 | return nil 183 | } 184 | 185 | func (service *DeploymentService) log(content string) { 186 | service.monitor.AddLog(content) 187 | 188 | } 189 | 190 | func (service *DeploymentService) unDeployFromPM2() error { 191 | var cmd *exec.Cmd 192 | 193 | if service.pr.Deployed { 194 | cmd = exec.Command("sh", "-c", "pm2 delete "+service.pr.GetPrId()) 195 | } else { 196 | err := fmt.Sprintf("there was no deployment with name %s to delete from pm2", service.pr.GetPrId()) 197 | service.log(err) 198 | return fmt.Errorf(err) 199 | } 200 | 201 | service.monitor.ListenToCmd(cmd) 202 | 203 | if startErr := cmd.Start(); startErr != nil { 204 | err := fmt.Sprintf("error while starting command to undeploy on pm2 : %s", startErr) 205 | service.log(err) 206 | return fmt.Errorf(err) 207 | } 208 | 209 | if waitErr := cmd.Wait(); waitErr != nil { 210 | err := fmt.Sprintf("error while executing command to undeploy on pm2 : %s", waitErr) 211 | service.log(err) 212 | return fmt.Errorf(err) 213 | } 214 | 215 | return nil 216 | } 217 | -------------------------------------------------------------------------------- /pkg/pull_request/pull_request.go: -------------------------------------------------------------------------------- 1 | package pull_request 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/rssb/imbere/pkg/constants" 10 | "github.com/rssb/imbere/pkg/db" 11 | "github.com/rssb/imbere/pkg/deployment" 12 | "github.com/rssb/imbere/pkg/process_monitor" 13 | ) 14 | 15 | // Concrete type that implements PullRequest 16 | 17 | type Event struct { 18 | name string 19 | action string 20 | } 21 | 22 | // gives a combination of event name(type) and action 23 | // ie. workflow_run.completed or pull_request.opened 24 | func (event *Event) GetNameAction() string { 25 | return event.name + "." + event.action 26 | } 27 | 28 | type PullRequestService struct { 29 | pr *db.PullRequest 30 | monitor *process_monitor.ProcessMonitor 31 | } 32 | 33 | func NewPullRequestService(pr *db.PullRequest, processMonitor *process_monitor.ProcessMonitor) *PullRequestService { 34 | 35 | return &PullRequestService{ 36 | pr: pr, 37 | monitor: processMonitor, 38 | } 39 | } 40 | 41 | func (service *PullRequestService) log(content string) { 42 | service.monitor.AddLog(content) 43 | } 44 | 45 | func (service *PullRequestService) removeDir() error { 46 | dirPath := constants.BUILD_DIR + service.pr.GetDir() 47 | 48 | if _, err := os.Stat(dirPath); !os.IsNotExist(err) { 49 | removeDirErr := os.RemoveAll(dirPath) 50 | 51 | if removeDirErr != nil { 52 | service.log(fmt.Sprintf("Failed to remove directory: %s", err.Error())) 53 | return removeDirErr 54 | } 55 | } 56 | service.log(fmt.Sprintf("Directory %s removed successfully", dirPath)) 57 | return nil 58 | } 59 | 60 | // When a pull request (PR) is created, a corresponding directory is generated that contains the changes introduced by the PR. 61 | // If a directory for the PR already exists, it is first deleted and then recreated to reflect the latest changes. 62 | // This approach ensures that any new pushes to the PR are incorporated, keeping the deployed code up-to-date. 63 | // The directory can later be deployed to any environment, enabling continuous integration and delivery. 64 | func (service *PullRequestService) prepareDir() (string, error) { 65 | dirPath := constants.BUILD_DIR + service.pr.GetDir() 66 | 67 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PREPARING_DIR, constants.PROCESS_OUTCOME_ONGOING) 68 | 69 | // check if dir exists and remove it 70 | if err := service.removeDir(); err != nil { 71 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PREPARING_DIR, constants.PROCESS_OUTCOME_FAILED) 72 | return "", err 73 | } 74 | 75 | // create dir 76 | err := os.MkdirAll(dirPath, 0755) 77 | if err != nil { 78 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PREPARING_DIR, constants.PROCESS_OUTCOME_FAILED) 79 | service.log(fmt.Sprintf("Failed to create directory: %s", err.Error())) 80 | return "", err 81 | } 82 | 83 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PREPARING_DIR, constants.PROCESS_OUTCOME_SUCCEEDED) 84 | service.log(fmt.Sprintf("Directory %s created successfully", dirPath)) 85 | 86 | return dirPath, nil 87 | } 88 | 89 | // Saves pr information in database, if Pull Request (PR ) already exists it will be updated 90 | // we track PR by ts pr_id 91 | func (service *PullRequestService) save() error { 92 | prRepo := db.PullRequestRepo{} 93 | return prRepo.Save(service.pr) 94 | } 95 | 96 | // In this section, we perform three key operations: 97 | // 1. Create a new directory for the incoming pull request. 98 | // 2. Pull the latest changes from the pull request into this directory. 99 | // 3. Save the current state of the pull request in the database. 100 | // These steps ensure that we have the most recent code changes isolated in a separate directory and the pull request's status is accurately tracked in the database. 101 | func (service *PullRequestService) PullChanges() error { 102 | _, err := exec.LookPath("git") 103 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_STARTED, constants.PROCESS_OUTCOME_ONGOING) 104 | 105 | // set pr as active, this is to indicate that the pr is currently being processed 106 | // this is important to avoid multiple processing of the same pr 107 | service.pr.IsDeploying = true 108 | service.save() 109 | 110 | if err != nil { 111 | service.log(fmt.Sprintf("Git not found: %s", err.Error())) 112 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_STARTED, constants.PROCESS_OUTCOME_FAILED) 113 | return err 114 | } 115 | 116 | // prepare cloning dir 117 | // the process about directory creation is communicated inside this method 118 | dirPath, err := service.prepareDir() 119 | if err != nil { 120 | return err 121 | } 122 | 123 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PULLING_CHANGES, constants.PROCESS_OUTCOME_ONGOING) 124 | 125 | cmd := exec.Command("git", "clone", "-b", service.pr.BranchName, "--single-branch", service.pr.RepoAddress, dirPath) 126 | cmd.Env = append(os.Environ(), 127 | "GIT_SSH_COMMAND=ssh -i ./.ssh/key -F /dev/null", 128 | ) 129 | 130 | service.monitor.ListenToCmd(cmd) // listen for outcome 131 | 132 | err = cmd.Start() 133 | if err != nil { 134 | service.log(fmt.Sprintf("Failed to clone repository: %s", err.Error())) 135 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PULLING_CHANGES, constants.PROCESS_OUTCOME_FAILED) 136 | return err 137 | } 138 | 139 | err = cmd.Wait() 140 | if err != nil { 141 | service.log(fmt.Sprintf("Failed to clone repository: %s", err.Error())) 142 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PULLING_CHANGES, constants.PROCESS_OUTCOME_FAILED) 143 | return err 144 | } 145 | 146 | service.log("Repository cloned successfully") 147 | service.monitor.UpdateProgress(constants.PROCESS_PROGRESS_PULLING_CHANGES, constants.PROCESS_OUTCOME_SUCCEEDED) 148 | 149 | err = service.save() 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return nil 155 | } 156 | 157 | func (service *PullRequestService) MarkActive() error { 158 | service.pr.Active = true 159 | return service.save() 160 | } 161 | 162 | func (service *PullRequestService) Deploy() error { 163 | if service.pr.IsDeploying { 164 | service.log(fmt.Sprintf("There is a deployment in progress for this PR ID: %s, skipping...", service.pr.GetPrId())) 165 | } else { 166 | 167 | err := service.PullChanges() 168 | 169 | if err != nil { 170 | return err 171 | } 172 | 173 | deploymentService := deployment.NewDeploymentService(service.pr, service.monitor) 174 | 175 | err = deploymentService.InstallDependencies() 176 | if err != nil { 177 | return err 178 | } 179 | 180 | err = deploymentService.Build() 181 | if err != nil { 182 | return err 183 | } 184 | 185 | err = deploymentService.Deploy() 186 | if err != nil { 187 | return err 188 | } 189 | 190 | } 191 | 192 | return nil 193 | } 194 | 195 | func (service *PullRequestService) UnDeploy() error { 196 | 197 | err := service.removeDir() 198 | 199 | if err != nil { 200 | return err 201 | } 202 | 203 | deploymentService := deployment.NewDeploymentService(service.pr, service.monitor) 204 | 205 | return deploymentService.UnDeploy() 206 | } 207 | 208 | func (service *PullRequestService) UpdateLabelToDeploy(isLabelPresent bool) error { 209 | service.pr.LabeledToDeploy = isLabelPresent 210 | 211 | log.Printf("Updating label to deploy %v", isLabelPresent) 212 | 213 | if isLabelPresent && !service.pr.Deployed { 214 | service.Deploy() 215 | } 216 | 217 | return service.save() 218 | } 219 | 220 | func HandlePR(event Event, payload map[string]interface{}) error { 221 | 222 | PR, err := CreateOrAssociatePullRequestFromPayload(event, payload) 223 | 224 | if err != nil { 225 | return err 226 | // utils.ReturnError(c, err.Error()) 227 | } 228 | 229 | nameAction := event.GetNameAction() 230 | isPullRequestOpenedOrReopened := nameAction == "pull_request.opened" || nameAction == "pull_request.reopened" 231 | isWorkflowRunCompleted := nameAction == "workflow_run.completed" 232 | shouldDeploy := isWorkflowRunCompleted && PR.LabeledToDeploy 233 | isPullRequestClosed := nameAction == "pull_request.closed" 234 | isPullRequestLabeled := nameAction == "pull_request.labeled" 235 | isPullRequestUnlabeled := nameAction == "pull_request.unlabeled" 236 | processMonitor := process_monitor.NewProcessMonitor(PR) 237 | 238 | prService := NewPullRequestService(PR, processMonitor) 239 | 240 | if isPullRequestOpenedOrReopened { 241 | return prService.MarkActive() 242 | } else if shouldDeploy { 243 | return prService.Deploy() 244 | } else if isPullRequestClosed { 245 | return prService.UnDeploy() 246 | } else if isPullRequestLabeled || isPullRequestUnlabeled { 247 | labelName, err := extractLabelName(event, payload) 248 | if err != nil { 249 | return err 250 | } 251 | 252 | if labelName == constants.DEPLOYMENT_LABEL { 253 | if isPullRequestLabeled { 254 | return prService.UpdateLabelToDeploy(true) 255 | } else if isPullRequestUnlabeled { 256 | return prService.UpdateLabelToDeploy(false) 257 | } 258 | } 259 | 260 | } 261 | 262 | return nil 263 | } 264 | 265 | func CommunicateProgress(status string) error { 266 | logger := log.Default() 267 | logger.Println(status) 268 | return nil 269 | } 270 | -------------------------------------------------------------------------------- /pkg/pull_request/helpers.go: -------------------------------------------------------------------------------- 1 | package pull_request 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/rssb/imbere/pkg/db" 10 | ) 11 | 12 | // createPullRequestFromPayload creates a PullRequest from the given payload. 13 | // It extracts the repository information, branch name, PR ID, PR number, and PR URL from the payload. 14 | // If any of these extractions fail, it returns an error. 15 | func CreateOrAssociatePullRequestFromPayload(event Event, payload map[string]interface{}) (*db.PullRequest, error) { 16 | repository, ok := payload["repository"].(map[string]interface{}) 17 | if !ok { 18 | return &db.PullRequest{}, fmt.Errorf("failed to parse repository from payload") 19 | } 20 | 21 | repositoryName, ok := repository["name"].(string) 22 | if !ok { 23 | return &db.PullRequest{}, fmt.Errorf("failed to parse repository name from payload") 24 | } 25 | 26 | repositoryAddress, ok := repository["html_url"].(string) 27 | if !ok { 28 | return &db.PullRequest{}, fmt.Errorf("failed to parse repository address from payload") 29 | } 30 | 31 | sshAddress, ok := repository["ssh_url"].(string) 32 | if !ok { 33 | return &db.PullRequest{}, fmt.Errorf("failed to parse ssh address from payload") 34 | } 35 | 36 | ownerName, ownerId, err := extractRepoOwnerInfo(payload) 37 | 38 | if err != nil { 39 | return &db.PullRequest{}, err 40 | } 41 | 42 | branchName, err := extractBranchName(event, payload) 43 | if err != nil { 44 | return &db.PullRequest{}, err 45 | } 46 | 47 | prId, err := extractPRID(event, payload) 48 | if err != nil { 49 | return &db.PullRequest{}, err 50 | } 51 | 52 | prNumber, err := extractPRNumber(event, payload) 53 | if err != nil { 54 | return &db.PullRequest{}, err 55 | } 56 | 57 | prUrl, err := extractUrl(event, payload) 58 | if err != nil { 59 | return &db.PullRequest{}, err 60 | } 61 | 62 | installationID, err := extractInstallationID(event, payload) 63 | if err != nil { 64 | return &db.PullRequest{}, err 65 | } 66 | 67 | prRepo := db.PullRequestRepo{} 68 | // Try to get the PR by its ID 69 | pr, err := prRepo.GetByPrID(prId) 70 | 71 | if err == nil && pr == nil { 72 | pr = &db.PullRequest{ 73 | PrID: prId, 74 | } 75 | } else if err != nil { 76 | return nil, err 77 | } 78 | 79 | // If the PR exists, update its fields 80 | pr.BranchName = branchName 81 | pr.RepoName = repositoryName 82 | pr.RepoAddress = repositoryAddress 83 | pr.SSHAddress = sshAddress 84 | pr.PrNumber = prNumber 85 | pr.PrUrl = prUrl 86 | pr.InstallationID = installationID 87 | pr.OwnerName = ownerName 88 | pr.OwnerID = ownerId 89 | 90 | return pr, nil 91 | } 92 | 93 | // The Event type is derived from the incoming payload. This type standardizes 94 | // the payload structure, 95 | func ExtractEventType(c *gin.Context, payload map[string]any) (Event, error) { 96 | // Get the X-GitHub-Event header 97 | eventType := c.GetHeader("X-GitHub-Event") 98 | 99 | if eventType == "" { 100 | return Event{}, errors.New("missing X-GitHub-Event header") 101 | } 102 | 103 | event := Event{ 104 | name: eventType, 105 | } 106 | 107 | if action, ok := payload["action"].(string); ok { 108 | // If there is 'action' key, return 'eventType.action' 109 | event.action = action 110 | return event, nil 111 | } 112 | 113 | return event, nil 114 | } 115 | 116 | func extractValueFromPayload(payload map[string]interface{}, path ...string) (interface{}, error) { 117 | var temp interface{} = payload 118 | 119 | for _, p := range path { 120 | switch v := temp.(type) { 121 | case map[string]interface{}: 122 | var ok bool 123 | temp, ok = v[p] 124 | if !ok { 125 | return nil, errors.New("Could not extract value - path does not exist") 126 | } 127 | case []interface{}: 128 | index, err := strconv.Atoi(p) 129 | if err != nil || index < 0 || index >= len(v) { 130 | return nil, errors.New("Could not extract value - invalid array index") 131 | } 132 | temp = v[index] 133 | default: 134 | return nil, errors.New("Could not extract value - path does not exist") 135 | } 136 | } 137 | 138 | return temp, nil 139 | } 140 | 141 | func extractBranchName(event Event, payload map[string]interface{}) (string, error) { 142 | var branchName string 143 | var err error 144 | var temp interface{} 145 | 146 | if event.name == "pull_request" { 147 | temp, err = extractValueFromPayload(payload, "pull_request", "head", "ref") 148 | } else if event.name == "workflow_run" { 149 | temp, err = extractValueFromPayload(payload, "workflow_run", "pull_requests", "0", "head", "ref") 150 | } 151 | 152 | if err != nil { 153 | return "", errors.New(event.GetNameAction() + " - Could not extract branch name: " + err.Error()) 154 | } 155 | 156 | branchName, ok := temp.(string) 157 | if !ok { 158 | return "", errors.New(event.GetNameAction() + " - Could not extract branch name: value is not a string") 159 | } 160 | 161 | return branchName, nil 162 | } 163 | 164 | func extractPRID(event Event, payload map[string]interface{}) (int64, error) { 165 | var err error 166 | var temp interface{} 167 | 168 | if event.name == "pull_request" { 169 | temp, err = extractValueFromPayload(payload, "pull_request", "id") 170 | } else if event.name == "workflow_run" { 171 | temp, err = extractValueFromPayload(payload, "workflow_run", "pull_requests", "0", "id") 172 | } 173 | 174 | if err != nil { 175 | return 0, errors.New(event.GetNameAction() + " - Could not extract pull request id: " + err.Error()) 176 | } 177 | 178 | prId, ok := temp.(float64) 179 | if !ok { 180 | return 0, errors.New(event.GetNameAction() + " - Could not extract pull request id: value is not a int64") 181 | } 182 | 183 | return int64(prId), nil 184 | } 185 | 186 | func extractPRNumber(event Event, payload map[string]interface{}) (int64, error) { 187 | var err error 188 | var temp interface{} 189 | 190 | if event.name == "pull_request" { 191 | temp, err = extractValueFromPayload(payload, "pull_request", "number") 192 | } else if event.name == "workflow_run" { 193 | temp, err = extractValueFromPayload(payload, "workflow_run", "pull_requests", "0", "number") 194 | } 195 | 196 | if err != nil { 197 | return 0, errors.New(event.GetNameAction() + " - Could not extract pull request number: " + err.Error()) 198 | } 199 | 200 | prNumber, ok := temp.(float64) 201 | if !ok { 202 | return 0, errors.New(event.GetNameAction() + " - Could not extract pull request number: value is not a int64") 203 | } 204 | 205 | return int64(prNumber), nil 206 | } 207 | 208 | func extractLabelName(event Event, payload map[string]interface{}) (string, error) { 209 | var err error 210 | var temp interface{} 211 | 212 | if event.name == "pull_request" { 213 | temp, err = extractValueFromPayload(payload, "label", "name") 214 | } 215 | 216 | if err != nil { 217 | return "", errors.New(event.GetNameAction() + " - Could not extract label name : " + err.Error()) 218 | } 219 | 220 | labelName, ok := temp.(string) 221 | 222 | if !ok { 223 | return "", errors.New(event.GetNameAction() + " - Could not extract label name: value is not a string") 224 | } 225 | 226 | return labelName, nil 227 | } 228 | 229 | func extractUrl(event Event, payload map[string]interface{}) (string, error) { 230 | var err error 231 | var temp interface{} 232 | 233 | if event.name == "pull_request" { 234 | temp, err = extractValueFromPayload(payload, "pull_request", "url") 235 | } else if event.name == "workflow_run" { 236 | temp, err = extractValueFromPayload(payload, "workflow_run", "pull_requests", "0", "url") 237 | } 238 | 239 | if err != nil { 240 | return "", errors.New(event.GetNameAction() + " - Could not extract pull request url: " + err.Error()) 241 | } 242 | 243 | prUrl, ok := temp.(string) 244 | if !ok { 245 | return "", errors.New(event.GetNameAction() + " - Could not extract pull request url: value is not a string") 246 | } 247 | 248 | return prUrl, nil 249 | } 250 | 251 | func extractInstallationID(event Event, payload map[string]interface{}) (int64, error) { 252 | var err error 253 | var temp interface{} 254 | 255 | temp, err = extractValueFromPayload(payload, "installation", "id") 256 | 257 | if err != nil { 258 | return 0, errors.New(event.GetNameAction() + " - Could not extract installation Id: " + err.Error()) 259 | } 260 | 261 | installationId, ok := temp.(float64) 262 | if !ok { 263 | return 0, errors.New(event.GetNameAction() + " - Could not extract installation Id: value is not a string") 264 | } 265 | 266 | return int64(installationId), nil 267 | } 268 | 269 | func extractRepoOwnerInfo(payload map[string]interface{}) (string, int64, error) { 270 | ownerNameValue, err := extractValueFromPayload(payload, "repository", "owner", "login") 271 | if err != nil { 272 | return "", 0, fmt.Errorf("failed to parse owner name from payload: %v", err) 273 | } 274 | 275 | ownerName, ok := ownerNameValue.(string) 276 | if !ok { 277 | return "", 0, fmt.Errorf("failed to parse owner name from payload: value is not a string") 278 | } 279 | 280 | ownerIDValue, err := extractValueFromPayload(payload, "repository", "owner", "id") 281 | if err != nil { 282 | return "", 0, fmt.Errorf("failed to parse owner ID from payload: %v", err) 283 | } 284 | 285 | ownerID, ok := ownerIDValue.(float64) 286 | if !ok { 287 | return "", 0, fmt.Errorf("failed to parse owner ID from payload: value is not an integer") 288 | } 289 | 290 | return ownerName, int64(ownerID), nil 291 | } 292 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bradleyfalzon/ghinstallation v1.1.1 h1:pmBXkxgM1WeF8QYvDLT5kuQiHMcmf+X015GI0KM/E3I= 2 | github.com/bradleyfalzon/ghinstallation v1.1.1/go.mod h1:vyCmHTciHx/uuyN82Zc3rXN3X2KTK8nUTCrTMwAhcug= 3 | github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= 4 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 5 | github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= 6 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 7 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 8 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 9 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 10 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 15 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 16 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 17 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 18 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 19 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 20 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 21 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 22 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 23 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 24 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 25 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 26 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 27 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 28 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 29 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 30 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 31 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 32 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 33 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 34 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 38 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 39 | github.com/google/go-github/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts= 40 | github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= 41 | github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= 42 | github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= 43 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 44 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 45 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 46 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 47 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 48 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 49 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 50 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 51 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 52 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 53 | github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI= 54 | github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= 55 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 56 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 57 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 58 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 59 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 60 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 61 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 62 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 63 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 64 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 65 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 66 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 67 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 68 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 69 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 70 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 71 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 72 | github.com/nao1215/markdown v0.4.0 h1:7+Z5xjAjeCPMTYheSEMxN9NpVGF8rT7uG4XJ5CSqWRA= 73 | github.com/nao1215/markdown v0.4.0/go.mod h1:ObBhnNduWwPN+bu4dtv4JoLRt57ONla7l//03iHIVhY= 74 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 75 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 76 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 77 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 78 | github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= 79 | github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= 80 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 81 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 82 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 83 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 84 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 85 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 86 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 87 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 88 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 89 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 90 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 91 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 92 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 93 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 94 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 95 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 96 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 97 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 98 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 99 | golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= 100 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 101 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 102 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= 103 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 104 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 105 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 106 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 107 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 108 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 109 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 112 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 113 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 114 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 115 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 116 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 117 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 118 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 119 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 120 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 121 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 122 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 123 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 124 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 125 | gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= 126 | gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= 127 | gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= 128 | gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 129 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 130 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 131 | --------------------------------------------------------------------------------