├── .github └── workflows │ ├── go.yml │ ├── golangci-lint.yml │ ├── release.yml │ └── release_nightly.yml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── config-example.yaml ├── flow.png ├── flow ├── config.go ├── flow.go ├── process.go └── process_test.go ├── gitbot ├── client.go ├── commitpr.go └── release.go ├── go.mod ├── go.sum └── main.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-go@v5 10 | with: 11 | go-version-file: "go.mod" 12 | id: go 13 | - run: go get -v -t -d ./... 14 | - run: go test ./... 15 | - run: go build -o flowd . 16 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches-ignore: 5 | - main 6 | jobs: 7 | golangci: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-go@v5 13 | with: 14 | go-version-file: "go.mod" 15 | - uses: golangci/golangci-lint-action@v4 16 | with: 17 | version: latest 18 | args: --disable govet 19 | 20 | # Optional: working directory, useful for monorepos 21 | # working-directory: somedir 22 | 23 | # Optional: golangci-lint command line arguments. 24 | # args: --issues-exit-code=0 25 | 26 | # Optional: show only new issues if it's a pull request. The default value is `false`. 27 | # only-new-issues: true 28 | 29 | # Optional: if set to true then the action will use pre-installed Go. 30 | # skip-go-installation: true 31 | 32 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 33 | # skip-pkg-cache: true 34 | 35 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 36 | # skip-build-cache: true 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | push_to_registry: 7 | name: Push Docker image to GitHub Docker Registry 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: docker/login-action@v3 12 | with: 13 | registry: docker.pkg.github.com 14 | username: ${{ github.actor }} 15 | password: ${{ secrets.GITHUB_TOKEN }} 16 | - id: meta 17 | uses: docker/metadata-action@v5 18 | with: 19 | images: docker.pkg.github.com/ubie-oss/flow/flow 20 | - uses: docker/build-push-action@v5 21 | with: 22 | push: true 23 | tags: ${{ steps.meta.outputs.tags }} 24 | labels: ${{ steps.meta.outputs.labels }} 25 | -------------------------------------------------------------------------------- /.github/workflows/release_nightly.yml: -------------------------------------------------------------------------------- 1 | name: Publish nightly Docker image 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | push: 8 | name: Push Docker image to GitHub Docker Registry 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: docker/login-action@v3 13 | with: 14 | registry: docker.pkg.github.com 15 | username: ${{ github.actor }} 16 | password: ${{ secrets.GITHUB_TOKEN }} 17 | - uses: docker/build-push-action@v5 18 | with: 19 | push: true 20 | tags: docker.pkg.github.com/ubie-oss/flow/flow:latest 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubie-oss/flow/94a4cefba21ff64f8d0f4230d1e2b01fe03ce880/.gitignore -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.2 as go 2 | FROM gcr.io/distroless/base-debian12 as run 3 | 4 | FROM go as build 5 | WORKDIR /go/src/github.com/ubie-oss/flow 6 | 7 | COPY go.mod . 8 | COPY go.sum . 9 | RUN go mod download 10 | 11 | COPY . . 12 | RUN CGO_ENABLED=0 go build -o /go/bin/server 13 | 14 | FROM run 15 | COPY --from=build /go/bin/server /usr/local/bin/server 16 | CMD ["server"] 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | run: 3 | go run ./main.go 4 | 5 | test: 6 | go test ./... 7 | 8 | PAYLOAD := $(shell echo '{"action": "INSERT", "tag": "sakajunquality/flow:abc123"}' | base64) 9 | test-message: 10 | curl -X POST http://localhost:8080 \ 11 | -H "Authorization: Bearer $$(gcloud auth print-identity-token)" \ 12 | -H 'Content-Type: application/json' \ 13 | -d '{"message": {"data": "$(PAYLOAD)"}}' 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flow 2 | =============== 3 | 4 | CL creator for GitOps style CI. 5 | 6 | ## Overview 7 | 8 | ![image](./flow.png) 9 | 10 | ## Usage 11 | 12 | To run locally, write your config file and set the path as `FLOW_CONFIG_PATH`. 13 | 14 | Then, get an access token from [the GitHub settings page](https://github.com/settings/tokens) and set it as `FLOW_GITHUB_TOKEN`. 15 | 16 | 17 | ```bash 18 | $ export FLOW_CONFIG_PATH=$PWD/config-example.yaml 19 | $ export FLOW_GITHUB_TOKEN=xxxxxx 20 | $ make run 21 | ``` 22 | 23 | Now, the flow app is waiting for pub/sub messages on http://localhost:8080. You can send a dummy request by executing the following command. 24 | 25 | ```bash 26 | $ make test-message 27 | ``` 28 | 29 | ## Test 30 | 31 | ```bash 32 | $ make test 33 | ``` 34 | 35 | ## License 36 | 37 | MIT license 38 | -------------------------------------------------------------------------------- /config-example.yaml: -------------------------------------------------------------------------------- 1 | applications: 2 | - image: gcr.io/$PROJECT_ID/foo 3 | source_owner: sakajunquality 4 | source_name: example-app 5 | manifest_owner: sakajunquality 6 | manifest_name: example-deployment 7 | manifest_base_branch: master 8 | manifests: 9 | - env: dev 10 | branch: dev 11 | commit_without_pr: true 12 | files: 13 | - overlays/dev/deployment.yaml 14 | - env: qa 15 | files: 16 | - overlays/qa/deployment.yaml 17 | filters: 18 | include_prefixes: 19 | - qa # qa.* 20 | - release # release.* 21 | - env: staging 22 | files: 23 | - overlays/staging/deployment.yaml 24 | filters: 25 | include_prefixes: 26 | - v # v.* 27 | - env: production 28 | files: 29 | - overlays/production/deployment.yaml 30 | filters: 31 | include_prefixes: 32 | - v # v.* 33 | pr_body: | 34 | THIS IS PRODUCTION 35 | 36 | git_author: 37 | name: sakajunquality 38 | email: test@sakajunquality.dev 39 | 40 | default_branch: main 41 | -------------------------------------------------------------------------------- /flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubie-oss/flow/94a4cefba21ff64f8d0f4230d1e2b01fe03ce880/flow.png -------------------------------------------------------------------------------- /flow/config.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | type Config struct { 4 | ApplicationList []Application `yaml:"applications"` 5 | GitAuthor GitAuthor `yaml:"git_author"` 6 | 7 | DefaultManifestOwner string `yaml:"default_manifest_owner"` 8 | DefaultManifestName string `yaml:"default_manifest_name"` 9 | DefaultBranch string `yaml:"default_branch"` 10 | } 11 | 12 | type Application struct { 13 | Name string `yaml:"name"` 14 | SourceOwner string `yaml:"source_owner"` 15 | SourceName string `yaml:"source_name"` 16 | ManifestOwner string `yaml:"manifest_owner"` 17 | ManifestName string `yaml:"manifest_name"` 18 | ManifestBaseBranch string `yaml:"manifest_base_branch"` 19 | 20 | RewriteNewTag bool `yaml:"rewrite_new_tag"` 21 | AdditionalRewriteKeys []string `yaml:"additional_rewrite_keys"` 22 | AdditionalRewritePrefix []string `yaml:"additional_rewrite_prefix"` 23 | 24 | Image string `yaml:"image"` 25 | Manifests []Manifest `yaml:"manifests"` 26 | } 27 | 28 | type Manifest struct { 29 | Env string `yaml:"env"` 30 | ShowSourceOwner bool `yaml:"show_source_owner"` 31 | HideSourceName bool `yaml:"hide_source_name"` 32 | HideSourceReleaseDesc bool `yaml:"hide_source_release_desc"` 33 | HideSourceReleasePullRequests bool `yaml:"hide_source_release_pull_requests"` 34 | ManifestOwner string `yaml:"manifest_owner"` 35 | ManifestName string `yaml:"manifest_name"` 36 | Files []string `yaml:"files"` 37 | Filters Filters `yaml:"filters"` 38 | PRBody string `yaml:"pr_body"` 39 | BaseBranch string `yaml:"base_branch"` 40 | CommitWithoutPR bool `yaml:"commit_without_pr"` 41 | Labels []string `yaml:"labels"` 42 | } 43 | 44 | type Filters struct { 45 | IncludePrefixes []string `yaml:"include_prefixes"` 46 | ExcludePrefixes []string `yaml:"exclude_prefixes"` 47 | } 48 | 49 | type GitAuthor struct { 50 | Name string `yaml:"name"` 51 | Email string `yaml:"email"` 52 | } 53 | -------------------------------------------------------------------------------- /flow/flow.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/sakajunquality/cloud-pubsub-events/gcrevent" 12 | ) 13 | 14 | var ( 15 | cfg *Config 16 | ) 17 | 18 | type Flow struct { 19 | Env string 20 | useApp bool 21 | githubToken *string 22 | githubAppID *int64 23 | githubAppInstlationID *int64 24 | githubAppPrivateKey *string 25 | enableVersionQuote bool 26 | enableAutoMerge bool 27 | } 28 | 29 | func New(c *Config) (*Flow, error) { 30 | cfg = c 31 | f := &Flow{} 32 | 33 | githubToken := os.Getenv("FLOW_GITHUB_TOKEN") 34 | githubAppID := os.Getenv("FLOW_GITHUB_APP_ID") 35 | githubAppInstlationID := os.Getenv("FLOW_GITHUB_APP_INSTALLATION_ID") 36 | githubAppPrivateKey := os.Getenv("FLOW_GITHUB_APP_PRIVATE_KEY") 37 | f.enableVersionQuote = os.Getenv("FLOW_ENABLE_VERSION_QUOTE") == "true" 38 | f.enableAutoMerge = os.Getenv("FLOW_ENABLE_AUTO_MERGE") == "true" 39 | f.githubToken = &githubToken 40 | 41 | if githubAppID != "" { 42 | f.useApp = true 43 | 44 | githubAppIDInt, err := strconv.ParseInt(githubAppID, 10, 64) 45 | if err != nil { 46 | return nil, errors.New("invalid value for FLOW_GITHUB_APP_ID") 47 | } 48 | f.githubAppID = &githubAppIDInt 49 | 50 | githubAppInstlationIDInt, err := strconv.ParseInt(githubAppInstlationID, 10, 64) 51 | if err != nil { 52 | return nil, errors.New("invalid value for FLOW_GITHUB_APP_INSTALLATION_ID") 53 | } 54 | f.githubAppInstlationID = &githubAppInstlationIDInt 55 | 56 | f.githubAppPrivateKey = &githubAppPrivateKey 57 | } 58 | 59 | if !f.useApp && f.githubToken == nil { 60 | return nil, errors.New("you need to specify a non-empty value for FLOW_GITHUB_TOKEN if you don't specify FLOW_GITHUB_APP_ID") 61 | } 62 | 63 | return f, nil 64 | } 65 | 66 | func (f *Flow) ProcessGCREvent(ctx context.Context, e gcrevent.Event) error { 67 | if e.Action != gcrevent.ActionInsert { 68 | return nil 69 | } 70 | 71 | if e.Tag == nil { 72 | return nil 73 | } 74 | 75 | parts := strings.Split(*e.Tag, ":") 76 | if len(parts) < 2 { 77 | return errors.New("invalid image tag or missing version") 78 | } 79 | image, version := parts[0], parts[1] 80 | 81 | if image == "" || version == "" { 82 | return fmt.Errorf("image format invalid: %s", *e.Tag) 83 | } 84 | 85 | return f.processImage(ctx, image, version) 86 | } 87 | -------------------------------------------------------------------------------- /flow/process.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/dlclark/regexp2" 12 | "github.com/google/go-github/v61/github" 13 | "github.com/ubie-oss/flow/v4/gitbot" 14 | ) 15 | 16 | type PullRequests []PullRequest 17 | 18 | type PullRequest struct { 19 | env string 20 | url string 21 | } 22 | 23 | const ( 24 | // Need to test every regex because failures in regexp2.MustCompile results in panic 25 | // rewrite version but do not if there is comment "# do-not-rewrite" or "# no-rewrite" 26 | versionRewriteRegex = "(?!.*(do-not-rewrite|no-rewrite).*)(version: +\"?(?[a-zA-Z0-9-_+.]*)\"?)" 27 | // the followings will be used with fmt.Sprintf and %s will be replaced 28 | imageRewriteRegexTemplate = "%s:(?[a-zA-Z0-9-_+.]*)" 29 | additionalRewriteKeysRegexTemplate = "%s: +\"?(?[a-zA-Z0-9-_+.]*)\"?" 30 | additionalRewritePrefixRegexTemplate = "%s(?[a-zA-Z0-9-_+.]*)" 31 | ) 32 | 33 | // Merge commit regex. 34 | var mergeCommitRegex = regexp2.MustCompile("^Merge pull request #(?\\d+) ", 0) 35 | 36 | func (f *Flow) processImage(ctx context.Context, image, version string) error { 37 | app, err := getApplicationByImage(image) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | prs := f.process(ctx, app, version) 43 | 44 | for _, pr := range prs { 45 | log.Printf("Processed PR: %s\n", pr.url) 46 | } 47 | return nil 48 | } 49 | 50 | func (f *Flow) getGitbotClient(ctx context.Context) *github.Client { 51 | if f.useApp { 52 | return gitbot.NewGitHubClientWithApp(ctx, *f.githubAppID, *f.githubAppInstlationID, *f.githubAppPrivateKey) 53 | } 54 | return gitbot.NewGitHubClient(ctx, *f.githubToken) 55 | } 56 | 57 | func (f *Flow) process(ctx context.Context, app *Application, version string) PullRequests { 58 | var prs PullRequests 59 | client := f.getGitbotClient(ctx) 60 | 61 | for _, manifest := range app.Manifests { 62 | if !shouldProcess(manifest, version) { 63 | continue 64 | } 65 | 66 | release := newRelease(*app, manifest, version) 67 | 68 | oldVersionSet := map[string]interface{}{} 69 | for _, filePath := range manifest.Files { 70 | release.MakeChangeFunc(ctx, client, filePath, fmt.Sprintf(imageRewriteRegexTemplate, app.Image), func(m regexp2.Match) string { 71 | oldVersionSet[m.GroupByName("version").String()] = nil 72 | return fmt.Sprintf("%s:%s", app.Image, version) 73 | }) 74 | release.MakeChangeFunc(ctx, client, filePath, versionRewriteRegex, func(m regexp2.Match) string { 75 | oldVersionSet[m.GroupByName("version").String()] = nil 76 | if f.enableVersionQuote { 77 | return fmt.Sprintf("version: \"%s\"", version) 78 | } 79 | return fmt.Sprintf("version: %s", version) 80 | }) 81 | 82 | for _, key := range app.AdditionalRewriteKeys { 83 | release.MakeChangeFunc(ctx, client, filePath, fmt.Sprintf(additionalRewriteKeysRegexTemplate, key), func(m regexp2.Match) string { 84 | oldVersionSet[m.GroupByName("version").String()] = nil 85 | if f.enableVersionQuote { 86 | return fmt.Sprintf("%s: \"%s\"", key, version) 87 | } 88 | return fmt.Sprintf("%s: %s", key, version) 89 | }) 90 | } 91 | for _, prefix := range app.AdditionalRewritePrefix { 92 | release.MakeChangeFunc(ctx, client, filePath, fmt.Sprintf(additionalRewritePrefixRegexTemplate, prefix), func(m regexp2.Match) string { 93 | oldVersionSet[m.GroupByName("version").String()] = nil 94 | return fmt.Sprintf("%s%s", prefix, version) 95 | }) 96 | } 97 | } 98 | 99 | oldVersions := []string{} 100 | for oldVersion := range oldVersionSet { 101 | oldVersions = append(oldVersions, oldVersion) 102 | } 103 | body := generateBody(ctx, client, app, manifest, version, oldVersions) 104 | release.SetBody(body) 105 | 106 | err := release.Commit(ctx, client) 107 | if err != nil { 108 | log.Printf("Error Commiting: %s", err) 109 | continue 110 | } 111 | 112 | if !manifest.CommitWithoutPR { 113 | url, err := release.CreatePR(ctx, client) 114 | if err != nil { 115 | log.Printf("Error Submitting PR: %s", err) 116 | continue 117 | } 118 | prs = append(prs, PullRequest{ 119 | env: manifest.Env, 120 | url: *url, 121 | }) 122 | 123 | if f.enableAutoMerge && url != nil { 124 | parts := strings.Split(*url, "/") 125 | // Extract repository owner and name from the URL 126 | // URL format: https://github.com/{owner}/{repo}/pull/{number} 127 | if len(parts) < 5 { 128 | log.Printf("Invalid PR URL format: %s", *url) 129 | continue 130 | } 131 | prNumber, err := strconv.Atoi(parts[len(parts)-1]) 132 | if err != nil { 133 | log.Printf("Error extracting PR number from URL %s: %s", *url, err) 134 | continue 135 | } 136 | repoOwner := parts[len(parts)-4] 137 | repoName := parts[len(parts)-3] 138 | 139 | _, _, err = client.PullRequests.Merge(ctx, repoOwner, repoName, prNumber, "Auto-merged by flow", &github.PullRequestOptions{ 140 | MergeMethod: "squash", 141 | }) 142 | if err != nil { 143 | log.Printf("Error merging PR #%d: %s", prNumber, err) 144 | } else { 145 | log.Printf("Successfully auto-merged PR #%d", prNumber) 146 | } 147 | } 148 | } 149 | } 150 | return prs 151 | } 152 | 153 | func shouldProcess(m Manifest, version string) bool { 154 | if version == "" { 155 | return false 156 | } 157 | // ignore latest tag 158 | if version == "latest" { 159 | return false 160 | } 161 | for _, prefix := range m.Filters.ExcludePrefixes { 162 | if strings.HasPrefix(version, prefix) { 163 | return false 164 | } 165 | } 166 | 167 | if len(m.Filters.IncludePrefixes) == 0 { 168 | return true 169 | } 170 | 171 | for _, prefix := range m.Filters.IncludePrefixes { 172 | if strings.HasPrefix(version, prefix) { 173 | return true 174 | } 175 | } 176 | 177 | return false 178 | } 179 | 180 | func newRelease(app Application, manifest Manifest, version string) gitbot.Release { 181 | branchName := getBranchName(app, manifest, version) 182 | message := getCommitMessage(app, manifest, version) 183 | 184 | // Use base a branch configured in app level 185 | baseBranch := app.ManifestBaseBranch 186 | 187 | // if baseBranch is not specified in each config use global 188 | if baseBranch == "" { 189 | baseBranch = cfg.DefaultBranch 190 | } 191 | 192 | // if not specified use master 193 | if baseBranch == "" { 194 | baseBranch = "master" 195 | } 196 | 197 | // If a branch is specified in each manifest use it 198 | if manifest.BaseBranch != "" { 199 | baseBranch = manifest.BaseBranch 200 | } 201 | 202 | // Commit in a new branch by default 203 | commitBranch := branchName 204 | // If manifest should be commited without a PR, commit to baseBranch 205 | if manifest.CommitWithoutPR { 206 | commitBranch = baseBranch 207 | } 208 | 209 | manifestOwner := cfg.DefaultManifestOwner 210 | if manifest.ManifestOwner != "" { 211 | manifestOwner = manifest.ManifestOwner 212 | } else if app.ManifestOwner != "" { 213 | manifestOwner = app.ManifestOwner 214 | } 215 | 216 | manifestName := cfg.DefaultManifestName 217 | if manifest.ManifestName != "" { 218 | manifestName = manifest.ManifestName 219 | } else if app.ManifestName != "" { 220 | manifestName = app.ManifestName 221 | } 222 | 223 | var labels []string 224 | labels = append(labels, app.SourceName) 225 | labels = append(labels, manifest.Env) 226 | labels = append(labels, manifest.Labels...) 227 | 228 | return gitbot.NewRelease( 229 | gitbot.Repo{ 230 | SourceOwner: manifestOwner, 231 | SourceRepo: manifestName, 232 | BaseBranch: baseBranch, 233 | CommitBranch: commitBranch, 234 | }, 235 | gitbot.Author{ 236 | Name: cfg.GitAuthor.Name, 237 | Email: cfg.GitAuthor.Email, 238 | }, 239 | message, 240 | "", 241 | labels, 242 | ) 243 | } 244 | 245 | func getBranchName(a Application, m Manifest, version string) string { 246 | branch := "rollout/" 247 | branch += m.Env 248 | 249 | if a.Name != "" { 250 | branch += "-" + a.Name 251 | } else { 252 | repo := a.SourceName 253 | if m.ShowSourceOwner { 254 | repo = fmt.Sprintf("%s-%s", a.SourceOwner, repo) 255 | } 256 | 257 | if !m.HideSourceName { 258 | branch += "-" + repo 259 | } 260 | } 261 | 262 | branch += "-" + version 263 | return branch 264 | } 265 | 266 | func getCommitMessage(a Application, m Manifest, version string) string { 267 | message := "Rollout" 268 | message += " " + m.Env 269 | 270 | if a.Name != "" { 271 | message += " " + a.Name 272 | } else { 273 | repo := a.SourceName 274 | if m.ShowSourceOwner { 275 | repo = fmt.Sprintf("%s/%s", a.SourceOwner, repo) 276 | } 277 | 278 | if !m.HideSourceName { 279 | message += " " + repo 280 | } 281 | } 282 | 283 | message += " " + version 284 | return message 285 | } 286 | 287 | func getApplicationByImage(image string) (*Application, error) { 288 | for _, app := range cfg.ApplicationList { 289 | if image == app.Image { 290 | return &app, nil 291 | } 292 | } 293 | return nil, errors.New("No application found for image " + image) 294 | } 295 | 296 | func generateBody(ctx context.Context, client *github.Client, app *Application, manifest Manifest, version string, oldVersions []string) string { 297 | var body string 298 | 299 | if !manifest.HideSourceReleaseDesc { 300 | body += "# Release\n" 301 | body += fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s\n", app.SourceOwner, app.SourceName, version) 302 | body += "\n" 303 | 304 | body += "## Changes\n\n" 305 | for _, oldVersion := range oldVersions { 306 | body += fmt.Sprintf("https://github.com/%s/%s/compare/%s...%s\n\n", app.SourceOwner, app.SourceName, oldVersion, version) 307 | if !manifest.HideSourceReleasePullRequests { 308 | body += "### Pull Requests\n\n" 309 | prNumbers := []int{} 310 | cmp, _, err := client.Repositories.CompareCommits(ctx, app.SourceOwner, app.SourceName, oldVersion, version, nil) 311 | if err != nil { 312 | log.Printf("Error compare commits: %s", err) 313 | continue 314 | } 315 | for _, commit := range cmp.Commits { 316 | if commit.Commit.Message != nil { 317 | m, err := mergeCommitRegex.FindStringMatch(*commit.Commit.Message) 318 | if err != nil { 319 | log.Printf("Error find string match: %s", err) 320 | continue 321 | } 322 | if m != nil { 323 | number, err := strconv.Atoi(m.GroupByName("number").String()) 324 | if err != nil { 325 | log.Printf("Error converting number string: %s", err) 326 | continue 327 | } 328 | prNumbers = append(prNumbers, number) 329 | } 330 | } 331 | } 332 | for _, number := range prNumbers { 333 | pr, _, err := client.PullRequests.Get(ctx, app.SourceOwner, app.SourceName, number) 334 | if err != nil { 335 | log.Printf("Error get pull request: %s", err) 336 | continue 337 | } 338 | body += fmt.Sprintf("- %s by @%s in %s/%s#%d\n", *pr.Title, *pr.User.Login, app.SourceOwner, app.SourceName, *pr.Number) 339 | } 340 | body += "\n" 341 | } 342 | } 343 | body += "\n" 344 | } 345 | 346 | if manifest.PRBody != "" { 347 | body += fmt.Sprintf("\n---\n%s", manifest.PRBody) 348 | } 349 | 350 | return body 351 | } 352 | -------------------------------------------------------------------------------- /flow/process_test.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | // "regexp" 5 | 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/dlclark/regexp2" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNewRelease(t *testing.T) { 14 | cfg = &Config{ 15 | GitAuthor: GitAuthor{ 16 | Name: "test", 17 | Email: "test@test.test", 18 | }, 19 | } 20 | 21 | app := Application{ 22 | SourceOwner: "wonderland", 23 | SourceName: "alice", 24 | ManifestBaseBranch: "master", 25 | } 26 | manifest := Manifest{ 27 | Env: "production", 28 | } 29 | version := "bar" 30 | 31 | r := newRelease(app, manifest, version) 32 | assert.Equal(t, "Rollout production alice bar", r.GetMessage()) 33 | assert.Equal(t, "rollout/production-alice-bar", r.GetRepo().CommitBranch) 34 | assert.Equal(t, "master", r.GetRepo().BaseBranch) 35 | assert.Equal(t, []string{"alice", "production"}, r.GetLabels()) 36 | 37 | manifest.BaseBranch = "production" 38 | r2 := newRelease(app, manifest, version) 39 | assert.Equal(t, "Rollout production alice bar", r2.GetMessage()) 40 | assert.Equal(t, "rollout/production-alice-bar", r2.GetRepo().CommitBranch) 41 | assert.Equal(t, "production", r2.GetRepo().BaseBranch) 42 | assert.Equal(t, []string{"alice", "production"}, r2.GetLabels()) 43 | 44 | manifest.CommitWithoutPR = true 45 | r3 := newRelease(app, manifest, version) 46 | assert.Equal(t, "Rollout production alice bar", r3.GetMessage()) 47 | assert.Equal(t, "production", r3.GetRepo().CommitBranch) 48 | assert.Equal(t, "production", r3.GetRepo().BaseBranch) 49 | assert.Equal(t, []string{"alice", "production"}, r3.GetLabels()) 50 | 51 | app2 := Application{ 52 | SourceOwner: "abc", 53 | SourceName: "123", 54 | } 55 | manifest2 := Manifest{ 56 | Env: "dev", 57 | BaseBranch: "dev", 58 | } 59 | 60 | r4 := newRelease(app2, manifest2, version) 61 | assert.Equal(t, "Rollout dev 123 bar", r4.GetMessage()) 62 | assert.Equal(t, "rollout/dev-123-bar", r4.GetRepo().CommitBranch) 63 | assert.Equal(t, "dev", r4.GetRepo().BaseBranch) 64 | 65 | manifest2.CommitWithoutPR = true 66 | r5 := newRelease(app2, manifest2, version) 67 | assert.Equal(t, "Rollout dev 123 bar", r5.GetMessage()) 68 | assert.Equal(t, "dev", r5.GetRepo().CommitBranch) 69 | assert.Equal(t, "dev", r5.GetRepo().BaseBranch) 70 | 71 | manifest2.Labels = []string{"bob"} 72 | r6 := newRelease(app, manifest2, version) 73 | assert.Equal(t, []string{"alice", "dev", "bob"}, r6.GetLabels()) 74 | 75 | cfg.DefaultBranch = "main" 76 | manifest.BaseBranch = "" 77 | app.ManifestBaseBranch = "" 78 | r7 := newRelease(app, manifest, version) 79 | assert.Equal(t, "main", r7.GetRepo().BaseBranch) 80 | 81 | manifest.BaseBranch = "master" 82 | r8 := newRelease(app, manifest, version) 83 | assert.Equal(t, "master", r8.GetRepo().BaseBranch) 84 | } 85 | 86 | func TestNewReleaseForDefaultOrg(t *testing.T) { 87 | cfg = &Config{ 88 | DefaultManifestOwner: "foo-inc", 89 | DefaultManifestName: "bar-repo", 90 | } 91 | 92 | app := Application{ 93 | SourceOwner: "wonderland", 94 | SourceName: "alice", 95 | ManifestBaseBranch: "master", 96 | } 97 | manifest := Manifest{ 98 | Env: "production", 99 | } 100 | version := "1234" 101 | 102 | r := newRelease(app, manifest, version) 103 | 104 | assert.Equal(t, "foo-inc", r.GetRepo().SourceOwner) 105 | assert.Equal(t, "bar-repo", r.GetRepo().SourceRepo) 106 | 107 | app.ManifestOwner = "abc-inc" 108 | app.ManifestName = "xyz-repo" 109 | 110 | r2 := newRelease(app, manifest, version) 111 | assert.Equal(t, "abc-inc", r2.GetRepo().SourceOwner) 112 | assert.Equal(t, "xyz-repo", r2.GetRepo().SourceRepo) 113 | 114 | manifest.ManifestOwner = "another-owner" 115 | manifest.ManifestName = "another-name" 116 | 117 | r3 := newRelease(app, manifest, version) 118 | assert.Equal(t, "another-owner", r3.GetRepo().SourceOwner) 119 | assert.Equal(t, "another-name", r3.GetRepo().SourceRepo) 120 | } 121 | 122 | func TestShouldProcess(t *testing.T) { 123 | // ignore empty and latest 124 | assert.Equal(t, false, shouldProcess(Manifest{}, "")) 125 | assert.Equal(t, false, shouldProcess(Manifest{}, "latest")) 126 | 127 | // usual tag 128 | assert.Equal(t, true, shouldProcess(Manifest{}, "foo")) 129 | 130 | // test include prefix 131 | m1 := Manifest{ 132 | Filters: Filters{ 133 | IncludePrefixes: []string{ 134 | "v", 135 | }, 136 | }, 137 | } 138 | assert.Equal(t, true, shouldProcess(m1, "v123")) 139 | assert.Equal(t, false, shouldProcess(m1, "release-foo")) 140 | 141 | // test exclude prefix 142 | m2 := Manifest{ 143 | Filters: Filters{ 144 | ExcludePrefixes: []string{ 145 | "v", 146 | }, 147 | }, 148 | } 149 | assert.Equal(t, false, shouldProcess(m2, "v123")) 150 | assert.Equal(t, true, shouldProcess(m2, "release-foo")) 151 | 152 | // mixed 153 | m3 := Manifest{ 154 | Filters: Filters{ 155 | IncludePrefixes: []string{ 156 | "v", 157 | }, 158 | ExcludePrefixes: []string{ 159 | "vv", 160 | }, 161 | }, 162 | } 163 | assert.Equal(t, true, shouldProcess(m3, "v123")) 164 | assert.Equal(t, false, shouldProcess(m3, "vv123")) 165 | assert.Equal(t, false, shouldProcess(m3, "release-foo")) 166 | } 167 | 168 | func TestGetBranchName(t *testing.T) { 169 | app := Application{ 170 | SourceOwner: "foo-inc", 171 | SourceName: "bar", 172 | } 173 | manifest := Manifest{ 174 | Env: "prod", 175 | } 176 | version := "v0.0.0" 177 | 178 | assert.Equal(t, "rollout/prod-bar-v0.0.0", getBranchName(app, manifest, version)) 179 | manifest.ShowSourceOwner = true 180 | assert.Equal(t, "rollout/prod-foo-inc-bar-v0.0.0", getBranchName(app, manifest, version)) 181 | manifest.HideSourceName = true 182 | assert.Equal(t, "rollout/prod-v0.0.0", getBranchName(app, manifest, version)) 183 | app.Name = "foo" 184 | assert.Equal(t, "rollout/prod-foo-v0.0.0", getBranchName(app, manifest, version)) 185 | } 186 | 187 | func TestGetCommitMessage(t *testing.T) { 188 | app := Application{ 189 | SourceOwner: "foo-inc", 190 | SourceName: "bar", 191 | } 192 | manifest := Manifest{ 193 | Env: "prod", 194 | } 195 | version := "v0.0.0" 196 | 197 | assert.Equal(t, "Rollout prod bar v0.0.0", getCommitMessage(app, manifest, version)) 198 | manifest.ShowSourceOwner = true 199 | assert.Equal(t, "Rollout prod foo-inc/bar v0.0.0", getCommitMessage(app, manifest, version)) 200 | manifest.HideSourceName = true 201 | assert.Equal(t, "Rollout prod v0.0.0", getCommitMessage(app, manifest, version)) 202 | } 203 | 204 | // test process() itself after refactoring 205 | func TestRegexTemplate(t *testing.T) { 206 | const ( 207 | oldVersion = "oldoldold" 208 | newVersion = "newnewnew" 209 | ) 210 | 211 | // test imageRewriteRegexTemplate 212 | const testImage = "gcr.io/foo/bar" 213 | image := regexp2.MustCompile(fmt.Sprintf(imageRewriteRegexTemplate, testImage), 0) 214 | r1, err := image.Replace(fmt.Sprintf("%s:%s", testImage, oldVersion), fmt.Sprintf("%s:%s", testImage, newVersion), 0, -1) 215 | assert.Nil(t, err) 216 | assert.Equal(t, r1, fmt.Sprintf("%s:%s", testImage, newVersion)) 217 | 218 | // test additionalRewriteKeysRegexTemplate 219 | const testKey = "hogefuga" 220 | rewriteKey := regexp2.MustCompile(fmt.Sprintf(additionalRewriteKeysRegexTemplate, testKey), 0) 221 | r2, err := rewriteKey.Replace(fmt.Sprintf("%s: %s", testKey, oldVersion), fmt.Sprintf("%s: %s", testKey, newVersion), 0, -1) 222 | assert.Nil(t, err) 223 | assert.Equal(t, r2, fmt.Sprintf("%s: %s", testKey, newVersion)) 224 | 225 | // test additionalRewritePrefixRegexTemplate 226 | const testPrefix = "-cprof_service_version=" 227 | rewritePrefix := regexp2.MustCompile(fmt.Sprintf(additionalRewritePrefixRegexTemplate, testPrefix), 0) 228 | r3, err := rewritePrefix.Replace(fmt.Sprintf("%s%s", testPrefix, oldVersion), fmt.Sprintf("%s%s", testPrefix, newVersion), 0, -1) 229 | assert.Nil(t, err) 230 | assert.Equal(t, r3, fmt.Sprintf("%s%s", testPrefix, newVersion)) 231 | } 232 | 233 | func TestVersionREwriteRegex(t *testing.T) { 234 | 235 | testcases := []struct { 236 | name string 237 | change func(m regexp2.Match) string 238 | original string 239 | expected string 240 | }{ 241 | { 242 | name: "no quotes", 243 | change: func(m regexp2.Match) string { return "version: xyz" }, 244 | original: ` 245 | version: abc 246 | version: 123 # do-not-rewrite 247 | test: 248 | foo: 249 | bar: 250 | version: abc 251 | foo: 252 | version: aiueo # no-rewrite 253 | `, 254 | expected: ` 255 | version: xyz 256 | version: 123 # do-not-rewrite 257 | test: 258 | foo: 259 | bar: 260 | version: xyz 261 | foo: 262 | version: aiueo # no-rewrite 263 | `, 264 | }, 265 | { 266 | name: "with quotes", 267 | change: func(m regexp2.Match) string { return `version: "xyz"` }, 268 | 269 | original: ` 270 | version: abc 271 | version: 123 # do-not-rewrite 272 | test: 273 | foo: 274 | bar: 275 | version: abc 276 | foo: 277 | version: aiueo # no-rewrite 278 | `, 279 | expected: ` 280 | version: "xyz" 281 | version: 123 # do-not-rewrite 282 | test: 283 | foo: 284 | bar: 285 | version: "xyz" 286 | foo: 287 | version: aiueo # no-rewrite 288 | `, 289 | }, 290 | } 291 | for _, tc := range testcases { 292 | t.Run(tc.name, func(t *testing.T) { 293 | 294 | re := regexp2.MustCompile(versionRewriteRegex, 0) 295 | result, err := re.ReplaceFunc(tc.original, tc.change, 0, -1) 296 | 297 | assert.Nil(t, err) 298 | assert.Equal(t, tc.expected, result) 299 | }) 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /gitbot/client.go: -------------------------------------------------------------------------------- 1 | package gitbot 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/bradleyfalzon/ghinstallation/v2" 9 | "github.com/google/go-github/v61/github" 10 | 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | func NewGitHubClient(ctx context.Context, token string) *github.Client { 15 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 16 | tc := oauth2.NewClient(ctx, ts) 17 | return github.NewClient(tc) 18 | } 19 | 20 | func NewGitHubClientWithApp(ctx context.Context, appID, installationID int64, privateKey string) *github.Client { 21 | tr := http.DefaultTransport 22 | itr, err := ghinstallation.New(tr, appID, installationID, []byte(privateKey)) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | return github.NewClient(&http.Client{Transport: itr}) 27 | } 28 | -------------------------------------------------------------------------------- /gitbot/commitpr.go: -------------------------------------------------------------------------------- 1 | package gitbot 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/dlclark/regexp2" 9 | "github.com/google/go-github/v61/github" 10 | ) 11 | 12 | func (r *release) getRef(ctx context.Context, client *github.Client) (ref *github.Reference, err error) { 13 | if ref, _, err = client.Git.GetRef(ctx, r.repo.SourceOwner, r.repo.SourceRepo, "refs/heads/"+r.repo.CommitBranch); err == nil { 14 | return ref, nil 15 | } 16 | 17 | var baseRef *github.Reference 18 | if baseRef, _, err = client.Git.GetRef(ctx, r.repo.SourceOwner, r.repo.SourceRepo, "refs/heads/"+r.repo.BaseBranch); err != nil { 19 | return nil, err 20 | } 21 | newRef := &github.Reference{Ref: github.String("refs/heads/" + r.repo.CommitBranch), Object: &github.GitObject{SHA: baseRef.Object.SHA}} 22 | ref, _, err = client.Git.CreateRef(ctx, r.repo.SourceOwner, r.repo.SourceRepo, newRef) 23 | return ref, err 24 | } 25 | 26 | func (r *release) makeChange(ctx context.Context, client *github.Client, filePath, regexText string, evaluator regexp2.MatchEvaluator) { 27 | // rewrite if target is already changed 28 | content, ok := r.changedContentMap[filePath] 29 | if ok { 30 | r.changedContentMap[filePath] = getChangedText(content, regexText, evaluator) 31 | return 32 | } 33 | 34 | content, err := r.getOriginalContent(ctx, client, filePath, r.repo.BaseBranch) 35 | if err != nil { 36 | log.Printf("Error fetching content %s", err) 37 | return 38 | } 39 | 40 | changed := getChangedText(content, regexText, evaluator) 41 | r.changedContentMap[filePath] = changed 42 | } 43 | 44 | func (r *release) getTree(ctx context.Context, client *github.Client, ref *github.Reference) (*github.Tree, error) { 45 | entries := []*github.TreeEntry{} 46 | for path, content := range r.changedContentMap { 47 | entries = append(entries, &github.TreeEntry{Path: github.String(path), Type: github.String("blob"), Content: github.String(content), Mode: github.String("100644")}) 48 | } 49 | 50 | tree, _, err := client.Git.CreateTree(ctx, r.repo.SourceOwner, r.repo.SourceRepo, *ref.Object.SHA, entries) 51 | return tree, err 52 | } 53 | 54 | func (r *release) pushCommit(ctx context.Context, client *github.Client, ref *github.Reference, tree *github.Tree) error { 55 | parent, _, err := client.Repositories.GetCommit(ctx, r.repo.SourceOwner, r.repo.SourceRepo, *ref.Object.SHA, nil) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | parent.Commit.SHA = parent.SHA 61 | 62 | date := time.Now() 63 | author := &github.CommitAuthor{Date: &github.Timestamp{date}, Name: &r.author.Name, Email: &r.author.Email} 64 | commit := &github.Commit{Author: author, Message: &r.message, Tree: tree, Parents: []*github.Commit{parent.Commit}} 65 | newCommit, _, err := client.Git.CreateCommit(ctx, r.repo.SourceOwner, r.repo.SourceRepo, commit, nil) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | ref.Object.SHA = newCommit.SHA 71 | _, _, err = client.Git.UpdateRef(ctx, r.repo.SourceOwner, r.repo.SourceRepo, ref, false) 72 | return err 73 | } 74 | 75 | func (r *release) createPR(ctx context.Context, client *github.Client) (*string, error) { 76 | newPR := &github.NewPullRequest{ 77 | Title: github.String(r.message), 78 | Head: github.String(r.repo.CommitBranch), 79 | Base: github.String(r.repo.BaseBranch), 80 | Body: github.String(r.body), 81 | MaintainerCanModify: github.Bool(true), 82 | } 83 | 84 | pr, _, err := client.PullRequests.Create(ctx, r.repo.SourceOwner, r.repo.SourceRepo, newPR) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | err = r.addLabels(ctx, client, *pr.Number) 90 | if err != nil { 91 | log.Printf("Error Adding Lables: %s", err) 92 | } 93 | 94 | return github.String(pr.GetHTMLURL()), nil 95 | } 96 | 97 | func (r *release) addLabels(ctx context.Context, client *github.Client, prNumber int) error { 98 | _, _, err := client.Issues.AddLabelsToIssue(ctx, r.repo.SourceOwner, r.repo.SourceRepo, prNumber, r.labels) 99 | return err 100 | } 101 | 102 | func (r *release) getOriginalContent(ctx context.Context, client *github.Client, filePath, baseBranch string) (string, error) { 103 | opt := &github.RepositoryContentGetOptions{ 104 | Ref: baseBranch, 105 | } 106 | 107 | f, _, _, err := client.Repositories.GetContents(ctx, r.repo.SourceOwner, r.repo.SourceRepo, filePath, opt) 108 | 109 | if err != nil { 110 | return "", err 111 | } 112 | 113 | return f.GetContent() 114 | } 115 | 116 | func getChangedText(original, regex string, evaluator regexp2.MatchEvaluator) string { 117 | re := regexp2.MustCompile(regex, 0) 118 | result, err := re.ReplaceFunc(original, evaluator, 0, -1) 119 | 120 | if err != nil { 121 | return original 122 | } 123 | 124 | return result 125 | } 126 | -------------------------------------------------------------------------------- /gitbot/release.go: -------------------------------------------------------------------------------- 1 | package gitbot 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/dlclark/regexp2" 8 | "github.com/google/go-github/v61/github" 9 | ) 10 | 11 | type release struct { 12 | repo Repo 13 | author Author 14 | message string 15 | body string 16 | labels []string 17 | changedContentMap map[string]string 18 | } 19 | 20 | type Release interface { 21 | MakeChange(ctx context.Context, client *github.Client, filePath, regexText, changedText string) 22 | MakeChangeFunc(ctx context.Context, client *github.Client, filePath, regexText string, evaluator regexp2.MatchEvaluator) 23 | Commit(ctx context.Context, client *github.Client) error 24 | CreatePR(ctx context.Context, client *github.Client) (*string, error) 25 | 26 | GetRepo() *Repo 27 | SetRepo(repo Repo) 28 | GetAuthor() *Author 29 | SetAuthor(author Author) 30 | GetMessage() string 31 | SetMessage(string) 32 | GetBody() string 33 | SetBody(string) 34 | GetLabels() []string 35 | SetLabels([]string) 36 | } 37 | 38 | type Repo struct { 39 | SourceOwner string 40 | SourceRepo string 41 | BaseBranch string 42 | CommitBranch string 43 | } 44 | 45 | var _ Release = &release{} 46 | 47 | type Author struct { 48 | Name string 49 | Email string 50 | } 51 | 52 | func NewRelease(repo Repo, author Author, message string, body string, labels []string) Release { 53 | return &release{ 54 | repo: repo, 55 | author: author, 56 | message: message, 57 | body: body, 58 | labels: labels, 59 | changedContentMap: make(map[string]string), 60 | } 61 | } 62 | 63 | func (r *release) MakeChange(ctx context.Context, client *github.Client, filePath, regexText, changedText string) { 64 | r.makeChange(ctx, client, filePath, regexText, func(regexp2.Match) string { return changedText }) 65 | } 66 | 67 | func (r *release) MakeChangeFunc(ctx context.Context, client *github.Client, filePath, regexText string, evaluator regexp2.MatchEvaluator) { 68 | r.makeChange(ctx, client, filePath, regexText, evaluator) 69 | } 70 | 71 | func (r *release) Commit(ctx context.Context, client *github.Client) error { 72 | ref, err := r.getRef(ctx, client) 73 | if err != nil { 74 | return err 75 | } 76 | if ref == nil { 77 | return errors.New("git reference was nil ") 78 | } 79 | 80 | tree, err := r.getTree(ctx, client, ref) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return r.pushCommit(ctx, client, ref, tree) 86 | } 87 | 88 | func (r *release) CreatePR(ctx context.Context, client *github.Client) (*string, error) { 89 | return r.createPR(ctx, client) 90 | } 91 | 92 | func (r *release) GetRepo() *Repo { return &r.repo } 93 | func (r *release) SetRepo(repo Repo) { r.repo = repo } 94 | func (r *release) GetAuthor() *Author { return &r.author } 95 | func (r *release) SetAuthor(author Author) { r.author = author } 96 | func (r *release) GetMessage() string { return r.message } 97 | func (r *release) SetMessage(s string) { r.message = s } 98 | func (r *release) GetBody() string { return r.body } 99 | func (r *release) SetBody(s string) { r.body = s } 100 | func (r *release) GetLabels() []string { return r.labels } 101 | func (r *release) SetLabels(labels []string) { r.labels = labels } 102 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ubie-oss/flow/v4 2 | 3 | require ( 4 | github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 5 | github.com/dlclark/regexp2 v1.11.5 6 | github.com/go-chi/chi/v5 v5.2.1 7 | github.com/go-chi/render v1.0.3 8 | github.com/google/go-github/v61 v61.0.0 9 | github.com/sakajunquality/cloud-pubsub-events v0.0.1 10 | github.com/stretchr/testify v1.10.0 11 | golang.org/x/oauth2 v0.27.0 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/ajg/form v1.5.1 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 19 | github.com/google/go-github/v69 v69.2.0 // indirect 20 | github.com/google/go-querystring v1.1.0 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | ) 23 | 24 | go 1.24.2 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 2 | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 3 | github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 h1:0D4vKCHOvYrDU8u61TnE2JfNT4VRrBLphmxtqazTO+M= 4 | github.com/bradleyfalzon/ghinstallation/v2 v2.14.0/go.mod h1:LOVmdZYVZ8jqdr4n9wWm1ocDiMz9IfMGfRkaYC1a52A= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 8 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 9 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 10 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 11 | github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= 12 | github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 13 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 14 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 15 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 16 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 17 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 18 | github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5pNlu1go= 19 | github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY= 20 | github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= 21 | github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= 22 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 23 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/sakajunquality/cloud-pubsub-events v0.0.1 h1:l2YisYCJjZ+3UwFfSe/+GKuX7pgpGExNeZaJqwFmw8w= 27 | github.com/sakajunquality/cloud-pubsub-events v0.0.1/go.mod h1:ge90hWT8vT100pJ3wdBk9uYRyQ+KXj91+CuCFA9j5R8= 28 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 29 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 30 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 31 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 32 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "github.com/go-chi/chi/v5/middleware" 14 | "github.com/go-chi/render" 15 | "github.com/sakajunquality/cloud-pubsub-events/gcrevent" 16 | "github.com/ubie-oss/flow/v4/flow" 17 | 18 | "gopkg.in/yaml.v3" 19 | ) 20 | 21 | // Response is a HTTP response 22 | type Response struct { 23 | Status int `json:"status"` 24 | } 25 | 26 | // PubSubMessage is a Push message from Cloud Pub/Sub 27 | type PubSubMessage struct { 28 | Message struct { 29 | Data []byte `json:"data,omitempty"` 30 | ID string `json:"id"` 31 | } `json:"message"` 32 | Subscription string `json:"subscription"` 33 | } 34 | 35 | var ( 36 | f *flow.Flow 37 | ) 38 | 39 | func main() { 40 | cfg, err := getConfig() 41 | if err != nil { 42 | fmt.Fprintf(os.Stderr, "Cloud not read the file:%s.\n", err) 43 | os.Exit(1) 44 | } 45 | 46 | f, err = initFlow(cfg) 47 | if err != nil { 48 | fmt.Fprintf(os.Stderr, "Error parsing the config %s.\n", err) 49 | os.Exit(1) 50 | } 51 | 52 | r := chi.NewRouter() 53 | 54 | r.Use(middleware.RequestID) 55 | r.Use(middleware.Logger) 56 | r.Post("/", handlePubSubMessage) 57 | 58 | port := os.Getenv("PORT") 59 | if port == "" { 60 | port = "8080" 61 | } 62 | log.Fatal(http.ListenAndServe(":"+port, r)) 63 | } 64 | 65 | func getConfig() ([]byte, error) { 66 | return os.ReadFile(os.Getenv("FLOW_CONFIG_PATH")) 67 | } 68 | 69 | func initFlow(config []byte) (*flow.Flow, error) { 70 | cfg := new(flow.Config) 71 | if err := yaml.Unmarshal(config, cfg); err != nil { 72 | fmt.Fprintf(os.Stderr, "yaml.Unmarshal error:%v.\n", err) 73 | os.Exit(1) 74 | } 75 | f, err := flow.New(cfg) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return f, nil 81 | } 82 | 83 | func handlePubSubMessage(w http.ResponseWriter, r *http.Request) { 84 | ctx := context.TODO() 85 | 86 | var m PubSubMessage 87 | body, err := io.ReadAll(r.Body) 88 | if err != nil { 89 | log.Printf("iotuil.ReadAll: %v", err) 90 | http.Error(w, "Bad Request", http.StatusBadRequest) 91 | return 92 | } 93 | 94 | if err := json.Unmarshal(body, &m); err != nil { 95 | log.Printf("json.Unmarshal: %v", err) 96 | http.Error(w, "Bad Request", http.StatusBadRequest) 97 | return 98 | } 99 | 100 | event, err := gcrevent.ParseMessage(m.Message.Data) 101 | if err != nil { 102 | log.Printf("gcrevent.ParseMessage: %v", err) 103 | http.Error(w, "Bad Request", http.StatusBadRequest) 104 | return 105 | } 106 | 107 | err = f.ProcessGCREvent(ctx, event) 108 | if err != nil { 109 | log.Printf("faild to process: %s", err) 110 | } 111 | 112 | res := &Response{ 113 | Status: http.StatusOK, 114 | } 115 | render.JSON(w, r, res) 116 | } 117 | --------------------------------------------------------------------------------