├── .dockerignore ├── .github ├── CODEOWNERS └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.txt ├── Taskfile.yml ├── ci ├── git-branch-or-tag.sh └── git-is-release.sh ├── cmd ├── cogito │ ├── main.go │ └── main_test.go └── templatedir │ └── main.go ├── cogito ├── buildinfo.go ├── check.go ├── check_test.go ├── gchatsink.go ├── gchatsink_private_test.go ├── gchatsink_test.go ├── get.go ├── get_test.go ├── ghcommitsink.go ├── ghcommitsink_private_test.go ├── ghcommitsink_test.go ├── protocol.go ├── protocol_test.go ├── put.go ├── put_test.go ├── putter.go ├── putter_private_test.go └── testdata │ ├── empty-dir │ └── .git_keepme │ ├── not-a-repo │ ├── a-dir │ │ └── .git_keepme │ └── a-file │ ├── one-repo │ └── a-repo │ │ └── dot.git │ │ ├── HEAD.template │ │ ├── config.template │ │ └── refs │ │ └── heads │ │ └── {{.branch_name}}.template │ ├── only-msgdir │ └── msgdir │ │ └── msg.txt │ ├── repo-and-msgdir │ ├── a-repo │ │ └── dot.git │ │ │ ├── HEAD.template │ │ │ ├── config.template │ │ │ └── refs │ │ │ └── heads │ │ │ └── {{.branch_name}}.template │ └── msgdir │ │ └── msg.txt │ ├── repo-bad-git-config │ └── dot.git │ │ └── config │ └── two-dirs │ ├── .git_keepme │ ├── dir-1 │ └── .git_keepme │ └── dir-2 │ ├── .git_keepme │ └── hello ├── doc ├── cogito-gchat.png ├── gh-ui-decorated.png └── version-is-missing.png ├── github ├── commitstatus.go ├── commitstatus_test.go ├── githubapp.go ├── githubapp_test.go ├── githuberror.go ├── retry.go ├── url.go └── url_test.go ├── go.mod ├── go.sum ├── googlechat ├── googlechat.go └── googlechat_test.go ├── pipelines ├── cogito-acceptance.yml ├── cogito.yml └── tasks │ └── generate-message-file.yml ├── retry ├── backoff.go ├── retry.go ├── retry_example_test.go └── retry_test.go ├── sets ├── sets.go └── sets_test.go └── testhelp ├── testhelper.go ├── testlog.go └── testserver.go /.dockerignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | .git/ 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | * @Pix4D/integration 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/en/actions/reference/ 2 | 3 | on: [push] 4 | 5 | name: ci 6 | 7 | env: 8 | go-version: 1.23.x 9 | task-version: v3.40.0 10 | 11 | jobs: 12 | all: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Install Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: ${{ env.go-version }} 19 | - name: Install Task 20 | run: go install github.com/go-task/task/v3/cmd/task@${{ env.task-version }} 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | with: 24 | # By default, actions/checkout will persist the GITHUB_TOKEN, so that further 25 | # steps in the job can perform authenticated git commands (that is: WRITE to 26 | # the repo). Following the Principle of least privilege, we disable this as long 27 | # as we don't need it. 28 | persist-credentials: false 29 | - run: task ci:setup 30 | - run: task lint 31 | - run: task build 32 | - run: task test:all 33 | env: 34 | COGITO_TEST_OAUTH_TOKEN: ${{ secrets.COGITO_TEST_OAUTH_TOKEN }} 35 | COGITO_TEST_GCHAT_HOOK: ${{ secrets.COGITO_TEST_GCHAT_HOOK }} 36 | COGITO_TEST_GH_APP_PRIVATE_KEY: | 37 | ${{ secrets.COGITO_TEST_GH_APP_PRIVATE_KEY }} 38 | - run: task docker:build 39 | - run: task docker:smoke 40 | - run: task docker:login 41 | env: 42 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 43 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 44 | - run: task docker:push 45 | - run: task docker:maybe-push-release 46 | - run: task ci:teardown 47 | # ALWAYS run this step, also if any previous step failed. 48 | if: always() 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .idea/ 3 | bin/ 4 | coverage.html 5 | coverage.out 6 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # See https://golangci-lint.run/usage/configuration/#config-file 2 | 3 | linters: 4 | # NOTE: `enable` _adds_ to the default linters! 5 | # To see the currently enabled linters, run `golangci-lint linters` 6 | enable: 7 | - gocritic 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine AS builder 2 | 3 | ARG BUILD_INFO 4 | 5 | ENV GOPATH=/root/go CGO_ENABLED=0 6 | 7 | WORKDIR /code 8 | 9 | # 10 | # Optimize downloading of dependencies only when they are needed. 11 | # This requires to _first_ copy only these two files, run `go mod download`, 12 | # and _then_ copy the rest of the source code. 13 | # 14 | COPY go.mod go.sum ./ 15 | RUN go mod download 16 | 17 | # 18 | # Build. 19 | # 20 | COPY . . 21 | 22 | RUN go test -short ./... 23 | RUN go install \ 24 | -ldflags "-w -X 'github.com/Pix4D/cogito/cogito.buildinfo=$BUILD_INFO'" \ 25 | ./cmd/cogito 26 | 27 | # 28 | # The final image 29 | # 30 | 31 | FROM alpine 32 | 33 | RUN apk --no-cache add ca-certificates 34 | 35 | RUN mkdir -p /opt/resource 36 | 37 | COPY --from=builder /root/go/bin/* /opt/resource/ 38 | 39 | RUN ln -s /opt/resource/cogito /opt/resource/check && \ 40 | ln -s /opt/resource/cogito /opt/resource/in && \ 41 | ln -s /opt/resource/cogito /opt/resource/out 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Pix4D SA and individual contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | TODO: 2 | 3 | [X] make repo public 4 | [X] dockerhub create repo 5 | [X] update readme with link to dockerhub 6 | [X] travis credentials 7 | [X] travis build status 8 | [X] minimal travis build (no push) 9 | [X] dockerignore 10 | [X] test credentials, real tests 11 | [X] build image on travis 12 | [X] dockerhub credentials 13 | [X] DOCKER_USERNAME 14 | [X] DOCKER_ORG 15 | [X] DOCKER_TOKEN 16 | [X] push images (with latest) to travis 17 | [X] update README, CONTRIBUTING, look for FIXME, TODO, WRITEME 18 | 19 | [X] merge PR to master 20 | [X] update CHANGELOG 21 | [X] test new image with tag `master` 22 | [ ] issue tag v0.3.0 23 | [ ] update our pipelines to use dockerhub and latest 24 | [ ] upgrade generate.py to use latest 25 | [ ] make post on discourse 26 | [ ] make PR for dutyfree ? 27 | 28 | [ ] BUG: the tag is not passed correctly any more: 29 | 30 | This is the Cogito GitHub status resource. Tag: , commit: e2d790c, build date: 2019-11-14 31 | 32 | [ ] use task inside dockerfile taskfile 33 | [ ] temporary: leave a way to build locally and push to our private registry. Maybe use a Concourse pipeline to mirror, as suggested by Alessandro. 34 | [ ] protect running task-publish, it should run only if in CI probably 35 | 36 | [ ] add a way to require e2e tests to run and fail if no env vars found 37 | 38 | [ ] replace the error types that do not take parameters because they are not helping to diagnose 39 | [ ] FIXME all statements of the form if gotErr != tc.wantErr 40 | are wrong; they should be if !errors.Is() 41 | 42 | [ ] CopyDir: FIXME longstanding bug: we apply template processing always, also if the file doesn't have the .template suffix! 43 | ] refactor helper.CopyDir() to be more composable in the transformers. 44 | 45 | [ ] MOST IMPORTANT parametric is fine, but I would like a way to use the testdata also when not running the e2e tests. Is it possible? Let's see: 46 | [ ] key names should be the same as the documented environment variables, but lowercase to hint that a potential transformation is happening: 47 | COGITO_TEST_REPO_OWNER -> {{.cogito_test_repo_owner}} 48 | COGITO_TEST_REPO_NAME -> {{.cogito_test_repo_name}} 49 | [ ] I should have stub default values for these keys, for example 50 | {{.cogito_test_repo_owner}} <- default_repo_owner 51 | {{.cogito_test_repo_name}} <- default_repo_name 52 | these stub values are used when the corresponding env variables are not set; this implies 53 | that the tests are using mocks instead than e2e 54 | [ ] fix the fact that I am using url = {{.repo_url}} instead of the individual variables, 55 | because this makes it possible to use only 1 file template to handle both SSH and HTTPS 56 | [ ] i need to carefully ask the question what am I testing? Am I testing cogito Out or am I testing GitHub status API? Or am I testing the full integration? 57 | [ ] still need a simple, not error-prone way to make the difference between e2e and not. Maybe I could reconsider making it more explicit by putting the e2e in a separate test executable, maybe with compilation guards? I don't know 58 | 59 | [ ] Add fake version for github tests, using httptest.NewServer as in hmsg 60 | [ ] Add fake version for resource tests, using httptest.NewServer as in hmsg 61 | [ ] Find a way to run also the E2E tests in the Docker build 62 | [ ] How do I test this thing ???? How do I pass the fake server to Out() ??? Need to wrap in two levels or to make the code read an env var :-( or to always run the real test (no fake). I can pass the server via the "source" map. But fro security (avoid exfiltrating the token) I don't accept the server, I accept a flag like test_api_server boolean. If set, the api server will be hardcoded to "localhost" ? 63 | [ ] CopyDir() the renamers can be replaced by a func (string) string 64 | 65 | [ ] cogito.yml sample pipeline: is there a trick to add a dependency between the two jobs without having to use S3 or equivalent ? Maybe a resource like semver, but without any external storage / external dependency ??? 66 | [ ] When I switched from "repo" to "input-repo" I got a bug because I didn't change all instances of the "repo" key. Two possibilities: 67 | 1. change the strings to constants everywhere 68 | 2. return error if an expected key doesn't exist. 69 | [ ] Taskfile: A clean target would be useful for removing the built docker images. 70 | 71 | [ ] prepare to open source it :-) 72 | [ ] in README explain that there is no public docker image OR take a decision if we want to provide it. It would be way better. 73 | [ ] completely paramterize the pipeline, to be used also outside pix4d 74 | [ ] add screenshots to the README to explain what is the context, the target_url and the description. 75 | 76 | [ ] move the testhelper to its own repo 77 | [ ] reduce docker image size 78 | [ ] do we gain anything from deleting some of the packages: 79 | apk del libc-utils alpine-baselayout alpine-keys busybox apk-tools 80 | [ ] why among the dependecies, ofcourse wants yaml ? The resource doesn't need it, it gets a JSON object. That yaml is problematic because it seems to be the one that requires gcc. If I can get rid of it, I can maybe reach a smaller image ? 81 | [ ] is there a way to use the busybox image (smaller) and bring on the certificates? Maybe I can use alpine to build, install the certs with apk, then copy the certs directory over to a busybox? Maybe, since this resource speaks only to github, I can even copy over only the cert of the CA of github ? 82 | 83 | [ ] better docker experience 84 | [ ] adding the go ldflags to the dockerfile as I did is wrong; now I rebuild way too often because docker detects that variables such as build time or commit hash have changed and decides to reinstall the packages!!! Fix this. 85 | 86 | [ ] probably I can replace reflect.TypeOf(err) with the new errors.Is() 87 | [ ] remove or update hmsg 88 | [ ] is there a newline or not in the gitref, when a tag is present? 89 | [ ] would it make sense to add error logging to sentry ? 90 | 91 | [ ] rename package resource to package cogito !!! 92 | [ ] package github: provide custom user agent (required by GH) 93 | [ ] How to parse .git/ref (created by the git resource) when it contains also a tag? 94 | .git/ref: Version reference detected and checked out. It will usually contain the commit SHA 95 | ref, but also the detected tag name when using tag_filter. 96 | 97 | [ ] investigate if this is a bug in path.Join() and open ticket if yes 98 | // it adds a 3rd slash "/": Post https:///api.github.c ... 99 | // API: POST /repos/:owner/:repo/statuses/:sha 100 | // try also with and without the beginning / for "repos" 101 | url := path.Join(s.server, "repos", s.owner, s.repo, "statuses", sha) 102 | 103 | [ ] package resource: add more tests for TestIn 104 | [ ] package resource: add more tests for TestCheck 105 | 106 | [ ] package resource: is there something cleaner than this "struct{}{}" thing ? 107 | mandatorySources = map[string]struct{}{ 108 | "owner_repo": struct{}{}, 109 | "access_token": struct{}{}, 110 | 111 | [ ] package resource: TestPut: 112 | find a way to test missing repo dir due to `input:` pipeline misconfiguration 113 | [ ] package resource: TestPut: 114 | find a way to test mismatch between input: and repo: 115 | 116 | [ ] package github: is it possible to return information about current rate limiting, to aid 117 | in throubleshooting? 118 | [ ] package github: is it possible to detect abuse rate limiting and report it, to help throubleshooting? On the other hand, this is already visible in the error message ... 119 | 120 | [ ] extract the userid from the commit :-D and make it available optionally 121 | [ ] add test TestGitHubStatusFake 122 | Use the http.testing API 123 | func TestGitHubStatusFake(t *testing.T) { 124 | fakeAPI := "http://localhost:8888" 125 | repoStatus := github.NewStatus(fakeAPI, ...) 126 | } 127 | 128 | [ ] add to TestGitHubStatusE2E(t *testing.T) 129 | Query the API to validate that the status has been added! But to do this, I need a unique text in the description, maybe I can just store the timestamp or generate an UUID and keep it in memory? 130 | 131 | [ ] Currently we validate that state is one of the valid values in the resource itself. 132 | Decide what do to among the following: 133 | - leave it there 134 | - move it to the github package 135 | - remove it completely, since GitHub will validate in any case 136 | Rationale: 137 | - since the final validation is done anycase in GitHub, what is the point of adding more code to have in any case a partial validation? 138 | - not validating allows to stay open: if tomorrow github adds another valid state, the resoulce will still work and support the new state withouh requiring a change (yes, not very probable, but still the reasoning make sense, no?) 139 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # Install `task` from https://taskfile.dev 2 | # Run `task --list` to start. 3 | 4 | version: "3" 5 | 6 | vars: 7 | DATE: '{{ now | date "2006-01-02" }}' 8 | REPO: github.com/Pix4D/cogito 9 | COMMIT: 10 | sh: git log -n 1 --format=%h 11 | BRANCH: 12 | sh: git branch --show-current 13 | DOCKER_ORG: '{{default "pix4d" .DOCKER_ORG}}' 14 | DOCKER_IMAGE: cogito 15 | DOCKER_TAG: 16 | sh: ci/git-branch-or-tag.sh 17 | IS_RELEASE: 18 | sh: ci/git-is-release.sh 19 | DOCKER_BASE_NAME: "{{.DOCKER_ORG}}/{{.DOCKER_IMAGE}}" 20 | DOCKER_FULL_NAME: "{{.DOCKER_BASE_NAME}}:{{.DOCKER_TAG}}" 21 | BUILD_INFO: "Tag: {{.DOCKER_TAG}}, commit: {{.COMMIT}}, build date: {{.DATE}}" 22 | LDFLAGS: -w -X '{{.REPO}}/cogito.buildinfo={{.BUILD_INFO}}' 23 | # 24 | SMOKE_INPUT: > 25 | { 26 | "source": {"owner": "foo", "repo": "bar", "access_token": "123", "log_level": "debug"}, 27 | "version": {"ref": "pizza"} 28 | } 29 | # 30 | GOLANGCI_VERSION: v1.62.2 31 | GOTESTSUM_VERSION: v1.12.0 32 | 33 | tasks: 34 | 35 | install:deps: 36 | desc: Install tool dependencies. 37 | cmds: 38 | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@{{.GOLANGCI_VERSION}} 39 | - go install gotest.tools/gotestsum@{{.GOTESTSUM_VERSION}} 40 | 41 | lint: 42 | desc: Lint the code. 43 | cmds: 44 | - golangci-lint run ./... 45 | 46 | test:env: 47 | desc: | 48 | Run what is passed on the command-line with a shell environment containing the secrets needed for the integration tests. 49 | Example: task test:env -- go test -count=1 -run 'TestFooIntegration' ./pkg/update" 50 | cmds: 51 | - '{{ .CLI_ARGS }}' 52 | env: &test-env 53 | COGITO_TEST_COMMIT_SHA: '{{default "751affd155db7a00d936ee6e9f483deee69c5922" .COGITO_TEST_COMMIT_SHA}}' 54 | COGITO_TEST_OAUTH_TOKEN: 55 | sh: 'echo {{default "$(gopass show cogito/test_oauth_token)" .COGITO_TEST_OAUTH_TOKEN}}' 56 | COGITO_TEST_REPO_NAME: '{{default "cogito-test-read-write" .COGITO_TEST_REPO_NAME}}' 57 | COGITO_TEST_REPO_OWNER: '{{default "pix4d" .COGITO_TEST_REPO_OWNER}}' 58 | COGITO_TEST_GCHAT_HOOK: 59 | sh: 'echo {{default "$(gopass show cogito/test_gchat_webhook)" .COGITO_TEST_GCHAT_HOOK}}' 60 | COGITO_TEST_GH_APP_CLIENT_ID: '{{default "Iv23lir9pyQlqmweDPbz" .COGITO_TEST_GH_APP_CLIENT_ID}}' 61 | COGITO_TEST_GH_APP_INSTALLATION_ID: '{{default "64650729" .COGITO_TEST_GH_APP_INSTALLATION_ID}}' 62 | COGITO_TEST_GH_APP_PRIVATE_KEY: 63 | sh: 'echo "{{default "$(gopass show cogito/test_gh_app_private_key)" .COGITO_TEST_GH_APP_PRIVATE_KEY}}"' 64 | 65 | test:unit: 66 | desc: Run the unit tests. 67 | cmds: 68 | # One day I will understand how to use -coverpkg=./... :-( 69 | - gotestsum -- -short -coverprofile=coverage.out ./... 70 | 71 | test:all: 72 | desc: Run all the tests (unit + integration). Use this target to get total coverage. 73 | cmds: 74 | - gotestsum -- -coverprofile=coverage.out ./... 75 | env: *test-env 76 | 77 | test:smoke: 78 | desc: Simple smoke test of the local executables. 79 | cmds: 80 | - task: build 81 | - task: build:templatedir 82 | - task: test:buildinfo 83 | - task: test:buildinfo 84 | - task: test:smoke:check 85 | - task: test:smoke:get 86 | #- task: test:smoke:put 87 | 88 | test:smoke:check: 89 | cmds: 90 | - echo '{{.SMOKE_INPUT}}' | ./bin/check 91 | 92 | test:smoke:get: 93 | cmds: 94 | - echo '{{.SMOKE_INPUT}}' | ./bin/in dummy-dir 95 | 96 | test:smoke:put: 97 | cmds: 98 | - rm -rf /tmp/cogito-test 99 | - mkdir -p /tmp/cogito-test 100 | - > 101 | ./bin/templatedir cogito/testdata/one-repo/a-repo /tmp/cogito-test --dot 102 | --template repo_url=https://github.com/foo/bar head=dummyHead 103 | branch_name=dummyBranch commit_sha=dummySha 104 | - echo '{{.PUT_INPUT}}' | ./bin/out /tmp/cogito-test 105 | vars: 106 | PUT_INPUT: > 107 | { 108 | "source": {"owner": "foo", "repo": "bar", "access_token": "123", "log_level": "debug"}, 109 | "params": {"state": "success"} 110 | } 111 | 112 | test:buildinfo: 113 | desc: Verify that the executable contains build information 114 | # cogito: This is the Cogito GitHub status resource. unknown 115 | # cogito: This is the Cogito GitHub status resource. Tag: buildinfo, commit: e9b36d0814, build date: 2022-07-26 116 | cmds: 117 | # "unknown" is the default value, printed when built without linker flags. 118 | - 'echo {{.OUTPUT}} | grep -v unknown' 119 | - 'echo {{.OUTPUT}} | grep Tag:' 120 | - 'echo {{.OUTPUT}} | grep commit:' 121 | vars: 122 | INPUT: '{"source": {"owner": "foo", "repo": "bar", "access_token": "123"}}' 123 | OUTPUT: 124 | # We only want to capture stderr, because the Cogito resource protocol uses 125 | # stderr for logging. 126 | sh: echo '{{.INPUT}}' | ./bin/check 2>&1 1>/dev/null 127 | 128 | fly-login: 129 | desc: Performs a fly login in the target to be used in the acceptance tests. 130 | cmds: 131 | - fly -t cogito login -c $(gopass show cogito/concourse_url) --open-browser 132 | 133 | test:acceptance:set-pipeline: 134 | desc: Set the acceptance test pipeline 135 | cmds: 136 | - > 137 | fly -t cogito set-pipeline --non-interactive -p {{.PIPELINE}} 138 | -c pipelines/cogito-acceptance.yml 139 | -y target-branch=stable 140 | -y cogito-branch={{.BRANCH}} 141 | -y github-owner=$(gopass show cogito/test_repo_owner) 142 | -y repo-name=$(gopass show cogito/test_repo_name) 143 | -y oauth-personal-access-token=$(gopass show cogito/test_oauth_token) 144 | -y cogito-tag={{.BRANCH}} 145 | -y gchat_webhook=$(gopass show cogito/test_gchat_webhook) 146 | -y github_app_client_id=$(gopass show cogito/github_app_client_id) 147 | -y github_app_installation_id=$(gopass show cogito/github_app_installation_id) 148 | -v github_app_private_key="$(gopass show cogito/github_app_private_key)" 149 | - fly -t cogito unpause-pipeline -p {{.PIPELINE}} 150 | vars: 151 | PIPELINE: cogito-acceptance---{{.BRANCH}} 152 | 153 | trigger-job: 154 | cmds: 155 | - fly -t cogito trigger-job -j {{.PIPELINE}}/{{.JOB}} -w 156 | vars: 157 | PIPELINE: cogito-acceptance---{{.BRANCH}} 158 | JOB: '{{.JOB}}' 159 | 160 | test:acceptance: 161 | desc: Run the Cogito acceptance tests. Needs a running Concourse. 162 | cmds: 163 | - task: test:acceptance:set-pipeline 164 | - task: test:acceptance:chat-only-summary 165 | - task: test:acceptance:chat-message-default 166 | - task: test:acceptance:chat-message-no-summary 167 | - task: test:acceptance:chat-message-file-default 168 | - task: test:acceptance:chat-message-only-sinks-override 169 | - task: test:acceptance:chat-message-only-simplest-possible 170 | - task: test:acceptance:chat-message-only-file 171 | - task: test:acceptance:default-log 172 | 173 | test:acceptance:chat-only-summary: 174 | desc: Run a pipeline job to test default chat 175 | cmds: 176 | - task: trigger-job 177 | vars: {JOB: chat-only-summary} 178 | 179 | test:acceptance:chat-message-default: 180 | desc: Run a pipeline job to test chat_message 181 | cmds: 182 | - task: trigger-job 183 | vars: {JOB: chat-message-default} 184 | 185 | test:acceptance:chat-message-no-summary: 186 | desc: Run a pipeline job to test chat_message and chat_append_summary 187 | cmds: 188 | - task: trigger-job 189 | vars: {JOB: chat-message-no-summary} 190 | 191 | test:acceptance:chat-message-file-default: 192 | desc: Run a pipeline job to test chat_message_file 193 | cmds: 194 | - task: trigger-job 195 | vars: {JOB: chat-message-file-default} 196 | 197 | test:acceptance:chat-message-only-simplest-possible: 198 | desc: Run a pipeline job to test chat only message 199 | cmds: 200 | - task: trigger-job 201 | vars: {JOB: chat-message-only-simplest-possible} 202 | 203 | test:acceptance:chat-message-only-sinks-override: 204 | desc: Run a pipeline job to test chat only message 205 | cmds: 206 | - task: trigger-job 207 | vars: {JOB: chat-message-only-sinks-override} 208 | 209 | test:acceptance:chat-message-only-file: 210 | desc: Run a pipeline job to test chat only chat_message_file 211 | cmds: 212 | - task: trigger-job 213 | vars: {JOB: chat-message-only-file} 214 | 215 | test:acceptance:default-log: 216 | desc: Run a pipeline job to test default logging 217 | cmds: 218 | - task: trigger-job 219 | vars: {JOB: default-log} 220 | 221 | browser: 222 | desc: "Show code coverage in browser (usage: task test: browser)" 223 | cmds: 224 | - go tool cover -html=coverage.out 225 | 226 | build: 227 | desc: Build on the local machine. 228 | dir: bin 229 | cmds: 230 | - go build -ldflags "{{.LDFLAGS}}" ../cmd/cogito 231 | - ln -sf cogito check 232 | - ln -sf cogito in 233 | - ln -sf cogito out 234 | 235 | build:templatedir: 236 | desc: Build templatedir (development helper, normally not needed). 237 | dir: bin 238 | cmds: 239 | - go build -ldflags "{{.LDFLAGS}}" ../cmd/templatedir 240 | 241 | clean: 242 | desc: Delete build artifacts 243 | cmds: 244 | - rm -f coverage.out 245 | - rm -r -f bin 246 | 247 | docker:login: 248 | cmds: 249 | - echo $DOCKER_TOKEN | docker login -u $DOCKER_USERNAME --password-stdin 250 | env: 251 | DOCKER_USERNAME: 252 | sh: 'echo {{default "$(gopass show cogito/docker_username)" .DOCKER_USERNAME}}' 253 | DOCKER_TOKEN: 254 | sh: 'echo {{default "$(gopass show cogito/docker_token)" .DOCKER_TOKEN}}' 255 | 256 | docker:build: 257 | desc: Build the Docker image. 258 | cmds: 259 | - docker build --build-arg BUILD_INFO --tag {{.DOCKER_FULL_NAME}} . 260 | - docker images {{.DOCKER_FULL_NAME}} 261 | env: 262 | BUILD_INFO: "{{.BUILD_INFO}}" 263 | 264 | docker:smoke: 265 | desc: Simple smoke test of the Docker image. 266 | cmds: 267 | - echo '{{.SMOKE_INPUT}}' | docker run --rm --interactive {{.DOCKER_FULL_NAME}} /opt/resource/check 268 | - echo 269 | - echo '{{.SMOKE_INPUT}}' | docker run --rm --interactive {{.DOCKER_FULL_NAME}} /opt/resource/in dummy-dir 270 | - echo 271 | 272 | docker:push: 273 | desc: Push the Docker image. 274 | cmds: 275 | - docker push {{.DOCKER_FULL_NAME}} 276 | - docker images {{.DOCKER_FULL_NAME}} 277 | preconditions: 278 | - sh: test -z "$IS_RELEASE" || test -n "$CI" 279 | msg: Release tag detected ({{.DOCKER_TAG}}); releases are made only on CI. 280 | 281 | docker:maybe-push-release: 282 | desc: If a release tag has been detected, Docker push with the 'latest' tag. 283 | cmds: 284 | - docker tag {{.DOCKER_FULL_NAME}} {{.DOCKER_BASE_NAME}}:latest 285 | - docker push {{.DOCKER_BASE_NAME}}:latest 286 | preconditions: 287 | - sh: test -n "$CI" 288 | msg: This target must run only on CI, not locally. 289 | status: 290 | - test -z "{{.IS_RELEASE}}" 291 | 292 | ci:setup: 293 | desc: Useful only when running under CI. 294 | cmds: 295 | - task: install:deps 296 | # Running "go mod download" is optional, since "go build" would do it anyway. 297 | # We run it explicitly to make the output of "go build" more focused. 298 | - cmd: go mod download -x 299 | 300 | # When using GitHub Actions, add this snippet at the end of the workflow: 301 | # - run: docker logout 302 | # # Always remove credentials, also if any previous step failed. 303 | # if: always() 304 | ci:teardown: 305 | desc: ALWAYS run this when in CI (reduces security exposures) 306 | cmds: 307 | # Remove credentials from the file system, added by "docker login" :-( 308 | - cmd: docker logout 309 | -------------------------------------------------------------------------------- /ci/git-branch-or-tag.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | # If we are on a branch, we don't even look to see if there are associated tags. 5 | BRANCH=$(git branch --show-current) 6 | if [ -n "$BRANCH" ]; then 7 | echo "$BRANCH" 8 | exit 0 9 | fi 10 | 11 | # 12 | # Here we are in detached HEAD state. 13 | # 14 | 15 | GIT_TAG=$(git tag --points-at HEAD) 16 | if [ -z "$GIT_TAG" ]; then 17 | echo "$0: Error: detached HEAD but not git tag?" 1>&2 18 | exit 1 19 | fi 20 | 21 | if ! echo "$GIT_TAG" | grep -P '^v\d+\.\d+\.\d+$' > /dev/null; then 22 | # Tag is not a version, print it as-is. 23 | echo "$GIT_TAG" 24 | exit 0 25 | fi 26 | 27 | # Tag is indeed a version. Strip the prefix "v" and print it. 28 | # (the `#` in the interpolation is equivalent to `^` in a regexp :-( ) 29 | echo "${GIT_TAG/#v/}" 30 | exit 0 31 | 32 | -------------------------------------------------------------------------------- /ci/git-is-release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | # If we are on a branch, we don't even look to see if there are associated tags. 5 | BRANCH=$(git branch --show-current) 6 | if [ -n "$BRANCH" ]; then 7 | exit 0 8 | fi 9 | 10 | # 11 | # Here we are in detached HEAD state. 12 | # 13 | 14 | GIT_TAG=$(git tag --points-at HEAD) 15 | if [ -z "$GIT_TAG" ]; then 16 | echo "$0: Error: detached HEAD but not git tag?" 1>&2 17 | exit 1 18 | fi 19 | 20 | if ! echo "$GIT_TAG" | grep -P '^v\d+\.\d+\.\d+$' > /dev/null; then 21 | # Tag is not a version. 22 | exit 0 23 | fi 24 | 25 | # Tag is indeed a version, thus this is a release. 26 | echo "TRUE" 27 | exit 0 28 | 29 | -------------------------------------------------------------------------------- /cmd/cogito/main.go: -------------------------------------------------------------------------------- 1 | // The three executables (check, in, out) are symlinked to this file. 2 | // For statically linked binaries like Go, this reduces the size by 2/3. 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "os" 11 | "path" 12 | 13 | "github.com/Pix4D/cogito/cogito" 14 | "github.com/Pix4D/cogito/sets" 15 | ) 16 | 17 | func main() { 18 | if err := mainErr(os.Stdin, os.Stdout, os.Stderr, os.Args); err != nil { 19 | fmt.Fprintf(os.Stderr, "cogito: error: %s\n", err) 20 | os.Exit(1) 21 | } 22 | } 23 | 24 | // The "Concourse resource protocol" expects: 25 | // - stdin, stdout and command-line arguments for the protocol itself 26 | // - stderr for logging 27 | // 28 | // See: https://concourse-ci.org/implementing-resource-types.html 29 | func mainErr(stdin io.Reader, stdout io.Writer, stderr io.Writer, args []string) error { 30 | cmd := path.Base(args[0]) 31 | validCmds := sets.From("check", "in", "out") 32 | if !validCmds.Contains(cmd) { 33 | return fmt.Errorf("invoked as '%s'; want: one of %v", cmd, validCmds) 34 | } 35 | 36 | input, err := io.ReadAll(stdin) 37 | if err != nil { 38 | return fmt.Errorf("reading stdin: %s", err) 39 | } 40 | 41 | logLevel, err := peekLogLevel(input) 42 | if err != nil { 43 | return err 44 | } 45 | var level slog.Level 46 | if err := level.UnmarshalText([]byte(logLevel)); err != nil { 47 | return fmt.Errorf("%s. (valid: debug, info, warn, error)", err) 48 | } 49 | removeTime := func(groups []string, a slog.Attr) slog.Attr { 50 | if a.Key == slog.TimeKey { 51 | return slog.Attr{} 52 | } 53 | return a 54 | } 55 | log := slog.New(slog.NewTextHandler( 56 | stderr, 57 | &slog.HandlerOptions{ 58 | Level: level, 59 | ReplaceAttr: removeTime, 60 | })) 61 | log.Info(cogito.BuildInfo()) 62 | 63 | switch cmd { 64 | case "check": 65 | return cogito.Check(log, input, stdout, args[1:]) 66 | case "in": 67 | return cogito.Get(log, input, stdout, args[1:]) 68 | case "out": 69 | putter := cogito.NewPutter(log) 70 | return cogito.Put(log, input, stdout, args[1:], putter) 71 | default: 72 | return fmt.Errorf("cli wiring error; please report") 73 | } 74 | } 75 | 76 | // peekLogLevel decodes 'input' as JSON and looks for key source.log_level. If 'input' 77 | // is not JSON, peekLogLevel will return an error. If 'input' is JSON but does not 78 | // contain key source.log_level, peekLogLevel returns "info" as default value. 79 | // 80 | // Rationale: depending on the Concourse step we are invoked for, the JSON object we get 81 | // from stdin is different, but it always contains a struct with name "source", thus we 82 | // can peek into it to gather the log level as soon as possible. 83 | func peekLogLevel(input []byte) (string, error) { 84 | type Peek struct { 85 | Source struct { 86 | LogLevel string `json:"log_level"` 87 | } `json:"source"` 88 | } 89 | var peek Peek 90 | peek.Source.LogLevel = "info" // default value 91 | if err := json.Unmarshal(input, &peek); err != nil { 92 | return "", fmt.Errorf("peeking into JSON for log_level: %s", err) 93 | } 94 | 95 | return peek.Source.LogLevel, nil 96 | } 97 | -------------------------------------------------------------------------------- /cmd/cogito/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "strconv" 11 | "strings" 12 | "testing" 13 | "testing/iotest" 14 | 15 | "gotest.tools/v3/assert" 16 | "gotest.tools/v3/assert/cmp" 17 | 18 | "github.com/Pix4D/cogito/cogito" 19 | "github.com/Pix4D/cogito/github" 20 | "github.com/Pix4D/cogito/googlechat" 21 | "github.com/Pix4D/cogito/testhelp" 22 | ) 23 | 24 | func TestRunCheckSuccess(t *testing.T) { 25 | stdin := strings.NewReader(` 26 | { 27 | "source": { 28 | "owner": "the-owner", 29 | "repo": "the-repo", 30 | "access_token": "the-secret", 31 | "log_level": "debug" 32 | } 33 | }`) 34 | var stdout bytes.Buffer 35 | var stderr bytes.Buffer 36 | 37 | err := mainErr(stdin, &stdout, &stderr, []string{"check"}) 38 | 39 | assert.NilError(t, err, "\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) 40 | } 41 | 42 | func TestRunGetSuccess(t *testing.T) { 43 | stdin := strings.NewReader(` 44 | { 45 | "source": { 46 | "owner": "the-owner", 47 | "repo": "the-repo", 48 | "access_token": "the-secret", 49 | "log_level": "debug" 50 | }, 51 | "version": {"ref": "pizza"} 52 | }`) 53 | var stdout bytes.Buffer 54 | var stderr bytes.Buffer 55 | 56 | err := mainErr(stdin, &stdout, &stderr, []string{"in", "dummy-dir"}) 57 | 58 | assert.NilError(t, err, "\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) 59 | } 60 | 61 | func TestRunPutSuccess(t *testing.T) { 62 | wantState := cogito.StateError 63 | wantGitRef := "dummyHead" 64 | var ghReq github.AddRequest 65 | var ghUrl *url.URL 66 | gitHubSpy := testhelp.SpyHttpServer(&ghReq, nil, &ghUrl, http.StatusCreated) 67 | gitHubSpyURL, err := url.Parse(gitHubSpy.URL) 68 | assert.NilError(t, err, "error parsing SpyHttpServer URL: %s", err) 69 | var chatMsg googlechat.BasicMessage 70 | chatReply := googlechat.MessageReply{} 71 | var gchatUrl *url.URL 72 | googleChatSpy := testhelp.SpyHttpServer(&chatMsg, chatReply, &gchatUrl, http.StatusOK) 73 | stdin := bytes.NewReader(testhelp.ToJSON(t, cogito.PutRequest{ 74 | Source: cogito.Source{ 75 | Owner: "the-owner", 76 | Repo: "the-repo", 77 | AccessToken: "the-secret", 78 | GhHostname: gitHubSpyURL.Host, 79 | GChatWebHook: googleChatSpy.URL, 80 | LogLevel: "debug", 81 | }, 82 | Params: cogito.PutParams{State: wantState}, 83 | })) 84 | var stdout bytes.Buffer 85 | var stderr bytes.Buffer 86 | inputDir := testhelp.MakeGitRepoFromTestdata(t, "../../cogito/testdata/one-repo/a-repo", 87 | testhelp.HttpsRemote(gitHubSpyURL.Host, "the-owner", "the-repo"), "dummySHA", wantGitRef) 88 | 89 | err = mainErr(stdin, &stdout, &stderr, []string{"out", inputDir}) 90 | 91 | assert.NilError(t, err, "\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) 92 | // 93 | gitHubSpy.Close() // Avoid races before the following asserts. 94 | assert.Equal(t, ghReq.State, string(wantState)) 95 | assert.Equal(t, path.Base(ghUrl.Path), wantGitRef) 96 | // 97 | googleChatSpy.Close() // Avoid races before the following asserts. 98 | assert.Assert(t, cmp.Contains(chatMsg.Text, "*state* 🟠 error")) 99 | } 100 | 101 | func TestRunPutSuccessIntegration(t *testing.T) { 102 | if testing.Short() { 103 | t.Skip("Skipping integration test (reason: -short)") 104 | } 105 | 106 | gitHubCfg := testhelp.GitHubSecretsOrFail(t) 107 | googleChatCfg := testhelp.GoogleChatSecretsOrFail(t) 108 | stdin := bytes.NewReader(testhelp.ToJSON(t, cogito.PutRequest{ 109 | Source: cogito.Source{ 110 | Owner: gitHubCfg.Owner, 111 | Repo: gitHubCfg.Repo, 112 | AccessToken: gitHubCfg.Token, 113 | GChatWebHook: googleChatCfg.Hook, 114 | LogLevel: "debug", 115 | }, 116 | Params: cogito.PutParams{State: cogito.StateError}, 117 | })) 118 | var stdout bytes.Buffer 119 | var stderr bytes.Buffer 120 | inputDir := testhelp.MakeGitRepoFromTestdata(t, "../../cogito/testdata/one-repo/a-repo", 121 | testhelp.HttpsRemote(github.GhDefaultHostname, gitHubCfg.Owner, gitHubCfg.Repo), gitHubCfg.SHA, 122 | "ref: refs/heads/a-branch-FIXME") 123 | t.Setenv("BUILD_JOB_NAME", "TestRunPutSuccessIntegration") 124 | t.Setenv("ATC_EXTERNAL_URL", "https://cogito.example") 125 | t.Setenv("BUILD_PIPELINE_NAME", "the-test-pipeline") 126 | t.Setenv("BUILD_TEAM_NAME", "the-test-team") 127 | t.Setenv("BUILD_NAME", "42") 128 | 129 | err := mainErr(stdin, &stdout, &stderr, []string{"out", inputDir}) 130 | 131 | assert.NilError(t, err, "\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String()) 132 | assert.Assert(t, cmp.Contains(stderr.String(), 133 | `level=INFO msg="commit status posted successfully" name=cogito.put name=ghCommitStatus state=error`)) 134 | assert.Assert(t, cmp.Contains(stderr.String(), 135 | `level=INFO msg="state posted successfully to chat" name=cogito.put name=gChat state=error`)) 136 | } 137 | 138 | func TestRunPutGhAppSuccessIntegration(t *testing.T) { 139 | if testing.Short() { 140 | t.Skip("Skipping integration test (reason: -short)") 141 | } 142 | 143 | gitHubCfg := testhelp.GitHubSecretsOrFail(t) 144 | ghAppInstallationID, err := strconv.ParseInt(gitHubCfg.GhAppInstallationID, 10, 32) 145 | assert.NilError(t, err) 146 | 147 | stdin := bytes.NewReader(testhelp.ToJSON(t, cogito.PutRequest{ 148 | Source: cogito.Source{ 149 | Owner: gitHubCfg.Owner, 150 | Repo: gitHubCfg.Repo, 151 | GitHubApp: github.GitHubApp{ 152 | ClientId: gitHubCfg.GhAppClientID, 153 | InstallationId: int(ghAppInstallationID), 154 | PrivateKey: gitHubCfg.GhAppPrivateKey, 155 | }, 156 | LogLevel: "debug", 157 | }, 158 | Params: cogito.PutParams{State: cogito.StateError}, 159 | })) 160 | var stdout bytes.Buffer 161 | var stderr bytes.Buffer 162 | inputDir := testhelp.MakeGitRepoFromTestdata(t, "../../cogito/testdata/one-repo/a-repo", 163 | testhelp.HttpsRemote(github.GhDefaultHostname, gitHubCfg.Owner, gitHubCfg.Repo), gitHubCfg.SHA, 164 | "ref: refs/heads/a-branch-FIXME") 165 | t.Setenv("BUILD_JOB_NAME", "TestRunPutGhAppSuccessIntegration") 166 | t.Setenv("ATC_EXTERNAL_URL", "https://cogito.example") 167 | t.Setenv("BUILD_PIPELINE_NAME", "the-test-pipeline") 168 | t.Setenv("BUILD_TEAM_NAME", "the-test-team") 169 | t.Setenv("BUILD_NAME", "42") 170 | 171 | err = mainErr(stdin, &stdout, &stderr, []string{"out", inputDir}) 172 | 173 | assert.NilError(t, err, "\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String()) 174 | assert.Assert(t, cmp.Contains(stderr.String(), 175 | `level=INFO msg="commit status posted successfully" name=cogito.put name=ghCommitStatus state=error`)) 176 | } 177 | 178 | func TestRunFailure(t *testing.T) { 179 | type testCase struct { 180 | name string 181 | args []string 182 | stdin string 183 | wantErr string 184 | } 185 | 186 | test := func(t *testing.T, tc testCase) { 187 | stdin := strings.NewReader(tc.stdin) 188 | 189 | err := mainErr(stdin, nil, io.Discard, tc.args) 190 | 191 | assert.ErrorContains(t, err, tc.wantErr) 192 | } 193 | 194 | testCases := []testCase{ 195 | { 196 | name: "unknown command", 197 | args: []string{"foo"}, 198 | wantErr: `invoked as 'foo'; want: one of [check in out]`, 199 | }, 200 | { 201 | name: "check, wrong stdin", 202 | args: []string{"check"}, 203 | stdin: ` 204 | { 205 | "fruit": "banana" 206 | }`, 207 | wantErr: `check: parsing request: json: unknown field "fruit"`, 208 | }, 209 | { 210 | name: "peeking for log_level", 211 | args: []string{"check"}, 212 | stdin: "", 213 | wantErr: "peeking into JSON for log_level: unexpected end of JSON input", 214 | }, 215 | } 216 | 217 | for _, tc := range testCases { 218 | t.Run(tc.name, func(t *testing.T) { 219 | assert.Assert(t, tc.wantErr != "") 220 | test(t, tc) 221 | }) 222 | } 223 | } 224 | 225 | func TestRunSystemFailure(t *testing.T) { 226 | stdin := iotest.ErrReader(errors.New("test read error")) 227 | 228 | err := mainErr(stdin, nil, io.Discard, []string{"check"}) 229 | 230 | assert.ErrorContains(t, err, "test read error") 231 | } 232 | 233 | func TestRunPrintsBuildInformation(t *testing.T) { 234 | stdin := strings.NewReader(` 235 | { 236 | "source": { 237 | "owner": "the-owner", 238 | "repo": "the-repo", 239 | "access_token": "the-secret" 240 | } 241 | }`) 242 | var stderr bytes.Buffer 243 | wantLog := "This is the Cogito GitHub status resource. unknown" 244 | 245 | err := mainErr(stdin, io.Discard, &stderr, []string{"check"}) 246 | assert.NilError(t, err) 247 | haveLog := stderr.String() 248 | 249 | assert.Assert(t, strings.Contains(haveLog, wantLog), 250 | "\nhave: %s\nwant: %s", haveLog, wantLog) 251 | } 252 | -------------------------------------------------------------------------------- /cmd/templatedir/main.go: -------------------------------------------------------------------------------- 1 | // Useful when developing testhelp.CopyDir. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/alexflint/go-arg" 10 | 11 | "github.com/Pix4D/cogito/testhelp" 12 | ) 13 | 14 | func main() { 15 | var args struct { 16 | Dot bool `arg:"help:rename dot.FOO to .FOO"` 17 | Template []string `arg:"help:template processing: key=val key=val ..."` 18 | Src string `arg:"positional,required,help:source directory"` 19 | Dst string `arg:"positional,required,help:destination directory"` 20 | } 21 | arg.MustParse(&args) 22 | templateData, err := makeTemplateData(args.Template) 23 | if err != nil { 24 | fmt.Println("error:", err) 25 | os.Exit(1) 26 | } 27 | 28 | var renamer testhelp.Renamer 29 | if args.Dot { 30 | renamer = testhelp.DotRenamer 31 | } else { 32 | renamer = testhelp.IdentityRenamer 33 | } 34 | 35 | if err := testhelp.CopyDir(args.Dst, args.Src, renamer, templateData); err != nil { 36 | fmt.Println("error:", err) 37 | os.Exit(1) 38 | } 39 | } 40 | 41 | // Take a list of strings of the form "key=value" and convert them to map entries. 42 | func makeTemplateData(keyvals []string) (testhelp.TemplateData, error) { 43 | data := testhelp.TemplateData{} 44 | for _, keyval := range keyvals { 45 | pos := strings.Index(keyval, "=") 46 | if pos == -1 { 47 | return data, fmt.Errorf("missing '=' in %s", keyval) 48 | } 49 | key := keyval[:pos] 50 | value := keyval[pos+1:] 51 | data[key] = value 52 | } 53 | return data, nil 54 | } 55 | -------------------------------------------------------------------------------- /cogito/buildinfo.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | // Baked in at build time with the linker. See the Taskfile and the Dockerfile. 4 | var buildinfo = "unknown" 5 | 6 | // BuildInfo returns human-readable build information (tag, git commit, date, ...). 7 | // This is useful to understand in the Concourse UI and logs which resource it is, since log 8 | // output in Concourse doesn't mention the name of the resource (or task) generating it. 9 | func BuildInfo() string { 10 | return "This is the Cogito GitHub status resource. " + buildinfo 11 | } 12 | -------------------------------------------------------------------------------- /cogito/check.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | ) 9 | 10 | // Check implements the "check" step (the "check" executable). 11 | // For the Cogito resource, this is a no-op. 12 | // 13 | // From https://concourse-ci.org/implementing-resource-types.html#resource-check: 14 | // 15 | // A resource type's check script is invoked to detect new versions of the resource. 16 | // It is given the configured source and current version on stdin, and must print the 17 | // array of new versions, in chronological order (oldest first), to stdout, including 18 | // the requested version if it is still valid. 19 | func Check(log *slog.Logger, input []byte, out io.Writer, args []string) error { 20 | log = log.With("name", "cogito.check") 21 | 22 | request, err := NewCheckRequest(input) 23 | if err != nil { 24 | return err 25 | } 26 | log.Debug("parsed check request", 27 | "source", request.Source, 28 | "version", request.Version, 29 | "environment", request.Env, 30 | "args", args) 31 | 32 | // We don't validate the presence of field request.Version because Concourse will 33 | // omit it from the _first_ request of the check step. 34 | 35 | // Here a normal resource would fetch a list of the latest versions. 36 | // In this resource, we do nothing. 37 | 38 | // Since there is no meaningful real version for this resource, we return always the 39 | // same dummy version. 40 | // NOTE I _think_ that when I initially wrote this, the JSON array of the versions 41 | // could not be empty. Now (2022-07) it seems that it could indeed be empty. 42 | // For the time being we keep it as-is because this maintains the previous behavior. 43 | // This will be investigated by PCI-2617. 44 | versions := []Version{DummyVersion} 45 | enc := json.NewEncoder(out) 46 | if err := enc.Encode(versions); err != nil { 47 | return fmt.Errorf("check: preparing output: %s", err) 48 | } 49 | 50 | log.Debug("success", "output.version", versions) 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /cogito/check_test.go: -------------------------------------------------------------------------------- 1 | package cogito_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | 8 | "gotest.tools/v3/assert" 9 | 10 | "github.com/Pix4D/cogito/cogito" 11 | "github.com/Pix4D/cogito/github" 12 | "github.com/Pix4D/cogito/testhelp" 13 | ) 14 | 15 | func TestCheckSuccess(t *testing.T) { 16 | type testCase struct { 17 | name string 18 | request cogito.CheckRequest 19 | wantOut []cogito.Version 20 | } 21 | 22 | test := func(t *testing.T, tc testCase) { 23 | in := testhelp.ToJSON(t, tc.request) 24 | var out bytes.Buffer 25 | log := testhelp.MakeTestLog() 26 | 27 | err := cogito.Check(log, in, &out, nil) 28 | 29 | assert.NilError(t, err) 30 | var have []cogito.Version 31 | testhelp.FromJSON(t, out.Bytes(), &have) 32 | assert.DeepEqual(t, have, tc.wantOut) 33 | } 34 | 35 | baseGithubSource := cogito.Source{ 36 | Owner: "the-owner", 37 | Repo: "the-repo", 38 | AccessToken: "the-token", 39 | } 40 | 41 | testCases := []testCase{ 42 | { 43 | name: "first request (Concourse omits the version field)", 44 | request: cogito.CheckRequest{ 45 | Source: baseGithubSource, 46 | }, 47 | wantOut: []cogito.Version{{Ref: "dummy"}}, 48 | }, 49 | { 50 | name: "subsequent requests (Concourse adds the version field)", 51 | request: cogito.CheckRequest{ 52 | Source: baseGithubSource, 53 | Version: cogito.Version{Ref: "dummy"}, 54 | }, 55 | wantOut: []cogito.Version{{Ref: "dummy"}}, 56 | }, 57 | } 58 | 59 | for _, tc := range testCases { 60 | t.Run(tc.name, func(t *testing.T) { 61 | test(t, tc) 62 | }) 63 | } 64 | } 65 | 66 | func TestCheckFailure(t *testing.T) { 67 | type testCase struct { 68 | name string 69 | source cogito.Source // will be embedded in cogito.CheckRequest 70 | writer io.Writer 71 | wantErr string 72 | } 73 | 74 | test := func(t *testing.T, tc testCase) { 75 | assert.Assert(t, tc.wantErr != "") 76 | in := testhelp.ToJSON(t, cogito.CheckRequest{Source: tc.source}) 77 | log := testhelp.MakeTestLog() 78 | 79 | err := cogito.Check(log, in, tc.writer, nil) 80 | 81 | assert.Error(t, err, tc.wantErr) 82 | } 83 | 84 | baseGithubSource := cogito.Source{ 85 | Owner: "the-owner", 86 | Repo: "the-repo", 87 | AccessToken: "the-token", 88 | } 89 | 90 | testCases := []testCase{ 91 | { 92 | name: "validation failure: missing access token or github_app", 93 | source: cogito.Source{}, 94 | writer: io.Discard, 95 | wantErr: "check: source: one of access_token or github_app must be specified", 96 | }, 97 | { 98 | name: "validation failure: both access_key and github_app are set", 99 | source: cogito.Source{ 100 | AccessToken: "dummy-token", 101 | GitHubApp: github.GitHubApp{ClientId: "client-id"}, 102 | }, 103 | writer: io.Discard, 104 | wantErr: "check: source: cannot specify both github_app and access_token", 105 | }, 106 | { 107 | name: "validation failure: missing repo keys", 108 | source: cogito.Source{AccessToken: "dummy-token"}, 109 | writer: io.Discard, 110 | wantErr: "check: source: missing keys: owner, repo", 111 | }, 112 | { 113 | name: "validation failure: missing gchat keys", 114 | source: cogito.Source{ 115 | Sinks: []string{"gchat"}, 116 | }, 117 | writer: io.Discard, 118 | wantErr: "check: source: missing keys: gchat_webhook", 119 | }, 120 | { 121 | name: "validation failure: wrong sink key", 122 | source: cogito.Source{ 123 | Sinks: []string{"ghost", "gchat"}, 124 | }, 125 | writer: io.Discard, 126 | wantErr: "check: source: invalid sink(s): [ghost]", 127 | }, 128 | { 129 | name: "write error", 130 | source: baseGithubSource, 131 | writer: &testhelp.FailingWriter{}, 132 | wantErr: "check: preparing output: test write error", 133 | }, 134 | } 135 | 136 | for _, tc := range testCases { 137 | t.Run(tc.name, func(t *testing.T) { 138 | test(t, tc) 139 | }) 140 | } 141 | } 142 | 143 | func TestCheckInputFailure(t *testing.T) { 144 | log := testhelp.MakeTestLog() 145 | 146 | err := cogito.Check(log, nil, io.Discard, nil) 147 | 148 | assert.Error(t, err, "check: parsing request: EOF") 149 | } 150 | -------------------------------------------------------------------------------- /cogito/gchatsink.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/fs" 7 | "log/slog" 8 | "strings" 9 | "time" 10 | 11 | "github.com/Pix4D/cogito/googlechat" 12 | ) 13 | 14 | // GoogleChatSink is an implementation of [Sinker] for the Cogito resource. 15 | type GoogleChatSink struct { 16 | Log *slog.Logger 17 | InputDir fs.FS 18 | GitRef string 19 | Request PutRequest 20 | } 21 | 22 | // Send sends a message to Google Chat if the configuration matches. 23 | func (sink GoogleChatSink) Send() error { 24 | sink.Log.Debug("send: started") 25 | defer sink.Log.Debug("send: finished") 26 | 27 | // If present, params.gchat_webhook overrides source.gchat_webhook. 28 | webHook := sink.Request.Source.GChatWebHook 29 | if sink.Request.Params.GChatWebHook != "" { 30 | webHook = sink.Request.Params.GChatWebHook 31 | sink.Log.Debug("params.gchat_webhook is overriding source.gchat_webhook") 32 | } 33 | if webHook == "" { 34 | sink.Log.Info("not sending to chat", "reason", "feature not enabled") 35 | return nil 36 | } 37 | 38 | state := sink.Request.Params.State 39 | if !shouldSendToChat(sink.Request) { 40 | sink.Log.Debug("not sending to chat", 41 | "reason", "state not in configured states", "state", state) 42 | return nil 43 | } 44 | 45 | text, err := prepareChatMessage(sink.InputDir, sink.Request, sink.GitRef) 46 | if err != nil { 47 | return fmt.Errorf("GoogleChatSink: %s", err) 48 | } 49 | 50 | threadKey := fmt.Sprintf("%s %s", sink.Request.Env.BuildPipelineName, sink.GitRef) 51 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 52 | defer cancel() 53 | reply, err := googlechat.TextMessage(ctx, sink.Log, webHook, threadKey, text) 54 | if err != nil { 55 | return fmt.Errorf("GoogleChatSink: %s", err) 56 | } 57 | 58 | sink.Log.Info("state posted successfully to chat", 59 | "state", state, "space", reply.Space.DisplayName, 60 | "sender", reply.Sender.DisplayName, "text", text) 61 | return nil 62 | } 63 | 64 | // shouldSendToChat returns true if the state is configured to do so. 65 | func shouldSendToChat(request PutRequest) bool { 66 | if request.Params.ChatMessage != "" || request.Params.ChatMessageFile != "" { 67 | return true 68 | } 69 | for _, x := range request.Source.ChatNotifyOnStates { 70 | if request.Params.State == x { 71 | return true 72 | } 73 | } 74 | return false 75 | } 76 | 77 | // prepareChatMessage returns a message ready to be sent to the chat sink. 78 | func prepareChatMessage(inputDir fs.FS, request PutRequest, gitRef string, 79 | ) (string, error) { 80 | params := request.Params 81 | 82 | var parts []string 83 | if params.ChatMessage != "" { 84 | parts = append(parts, params.ChatMessage) 85 | } 86 | if params.ChatMessageFile != "" { 87 | contents, err := fs.ReadFile(inputDir, params.ChatMessageFile) 88 | if err != nil { 89 | return "", fmt.Errorf("reading chat_message_file: %s", err) 90 | } 91 | parts = append(parts, string(contents)) 92 | } 93 | 94 | if len(parts) == 0 || (len(parts) > 0 && params.ChatAppendSummary) { 95 | parts = append( 96 | parts, 97 | gChatBuildSummaryText(gitRef, params.State, request.Source, request.Env)) 98 | } 99 | 100 | return strings.Join(parts, "\n\n"), nil 101 | } 102 | 103 | // gChatBuildSummaryText returns a plain text message to be sent to Google Chat. 104 | func gChatBuildSummaryText(gitRef string, state BuildState, src Source, env Environment, 105 | ) string { 106 | now := time.Now().Format("2006-01-02 15:04:05 MST") 107 | 108 | // Google Chat format for links with alternate name: 109 | // 110 | // GitHub link to commit: 111 | // https://github.com/Pix4D/cogito/commit/e8c6e2ac0318b5f0baa3f55 112 | job := fmt.Sprintf("<%s|%s/%s>", 113 | concourseBuildURL(env), env.BuildJobName, env.BuildName) 114 | 115 | // Unfortunately the font is proportional and doesn't support tabs, 116 | // so we cannot align in columns. 117 | var bld strings.Builder 118 | fmt.Fprintf(&bld, "%s\n", now) 119 | fmt.Fprintf(&bld, "*pipeline* %s\n", env.BuildPipelineName) 120 | fmt.Fprintf(&bld, "*job* %s\n", job) 121 | fmt.Fprintf(&bld, "*state* %s\n", decorateState(state)) 122 | // An empty gitRef means that cogito has been configured as chat only. 123 | if gitRef != "" { 124 | commitUrl := fmt.Sprintf("https://%s/%s/%s/commit/%s", 125 | src.GhHostname, src.Owner, src.Repo, gitRef) 126 | commit := fmt.Sprintf("<%s|%.10s> (repo: %s/%s)", 127 | commitUrl, gitRef, src.Owner, src.Repo) 128 | fmt.Fprintf(&bld, "*commit* %s\n", commit) 129 | } 130 | 131 | return bld.String() 132 | } 133 | 134 | func decorateState(state BuildState) string { 135 | var icon string 136 | switch state { 137 | case StateAbort: 138 | icon = "🟤" 139 | case StateError: 140 | icon = "🟠" 141 | case StateFailure: 142 | icon = "🔴" 143 | case StatePending: 144 | icon = "🟡" 145 | case StateSuccess: 146 | icon = "🟢" 147 | default: 148 | icon = "❓" 149 | } 150 | 151 | return fmt.Sprintf("%s %s", icon, state) 152 | } 153 | -------------------------------------------------------------------------------- /cogito/gchatsink_private_test.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "testing/fstest" 7 | 8 | "gotest.tools/v3/assert" 9 | "gotest.tools/v3/assert/cmp" 10 | ) 11 | 12 | func TestShouldSendToChatDefaultConfig(t *testing.T) { 13 | type testCase struct { 14 | state BuildState 15 | want bool 16 | } 17 | 18 | test := func(t *testing.T, tc testCase) { 19 | request := PutRequest{} 20 | request.Source.ChatNotifyOnStates = defaultNotifyStates 21 | request.Params.State = tc.state 22 | 23 | assert.Equal(t, shouldSendToChat(request), tc.want) 24 | } 25 | 26 | testCases := []testCase{ 27 | {state: StateAbort, want: true}, 28 | {state: StateError, want: true}, 29 | {state: StateFailure, want: true}, 30 | {state: StatePending, want: false}, 31 | {state: StateSuccess, want: false}, 32 | } 33 | 34 | for _, tc := range testCases { 35 | t.Run(string(tc.state), func(t *testing.T) { test(t, tc) }) 36 | } 37 | } 38 | 39 | func TestShouldSendToChatCustomConfig(t *testing.T) { 40 | type testCase struct { 41 | state BuildState 42 | want bool 43 | } 44 | 45 | test := func(t *testing.T, tc testCase) { 46 | request := PutRequest{} 47 | request.Source.ChatNotifyOnStates = []BuildState{StatePending, StateSuccess} 48 | request.Params.State = tc.state 49 | 50 | assert.Equal(t, shouldSendToChat(request), tc.want) 51 | } 52 | 53 | testCases := []testCase{ 54 | {state: StateAbort, want: false}, 55 | {state: StateError, want: false}, 56 | {state: StateFailure, want: false}, 57 | {state: StatePending, want: true}, 58 | {state: StateSuccess, want: true}, 59 | } 60 | 61 | for _, tc := range testCases { 62 | t.Run(string(tc.state), func(t *testing.T) { test(t, tc) }) 63 | } 64 | } 65 | 66 | func TestPrepareChatMessageOnlyChatSuccess(t *testing.T) { 67 | have, err := prepareChatMessage(nil, PutRequest{}, "") 68 | 69 | assert.NilError(t, err) 70 | assert.Check(t, !strings.Contains(have, "commit"), "not wanted: commit") 71 | } 72 | 73 | func TestPrepareChatMessageSuccess(t *testing.T) { 74 | type testCase struct { 75 | name string 76 | makeReq func() PutRequest 77 | inputDir fstest.MapFS 78 | wantPresent []string 79 | wantAbsent []string 80 | } 81 | 82 | baseRequest := PutRequest{ 83 | Source: Source{ 84 | Owner: "the-owner", 85 | ChatAppendSummary: true, // the default 86 | }, 87 | Params: PutParams{ 88 | State: StateError, 89 | ChatAppendSummary: true, // the default 90 | }, 91 | Env: Environment{BuildJobName: "the-job"}, 92 | } 93 | 94 | baseGitRef := "deadbeef" 95 | 96 | buildSummary := []string{ 97 | baseRequest.Source.Owner, baseRequest.Env.BuildJobName, baseGitRef} 98 | customMessage := "the-custom-message" 99 | customFile := "from-custom-file" 100 | 101 | test := func(t *testing.T, tc testCase) { 102 | have, err := prepareChatMessage(tc.inputDir, tc.makeReq(), baseGitRef) 103 | 104 | assert.NilError(t, err) 105 | for _, elem := range tc.wantPresent { 106 | assert.Check(t, strings.Contains(have, elem), "wanted: %s", elem) 107 | } 108 | for _, elem := range tc.wantAbsent { 109 | assert.Check(t, !strings.Contains(have, elem), "not wanted: %s", elem) 110 | } 111 | } 112 | 113 | testCases := []testCase{ 114 | { 115 | name: "build summary only", 116 | makeReq: func() PutRequest { return baseRequest }, 117 | wantPresent: buildSummary, 118 | wantAbsent: []string{customMessage}, 119 | }, 120 | { 121 | name: "chat_message, all defaults", 122 | makeReq: func() PutRequest { 123 | req := baseRequest 124 | req.Params.ChatMessage = customMessage 125 | return req 126 | }, 127 | wantPresent: append([]string{customMessage}, buildSummary...), 128 | }, 129 | { 130 | name: "chat_message, params.append false", 131 | makeReq: func() PutRequest { 132 | req := baseRequest 133 | req.Params.ChatMessage = customMessage 134 | req.Params.ChatAppendSummary = false 135 | return req 136 | }, 137 | wantPresent: []string{customMessage}, 138 | wantAbsent: buildSummary, 139 | }, 140 | { 141 | name: "chat_message_file, all defaults", 142 | makeReq: func() PutRequest { 143 | req := baseRequest 144 | req.Params.ChatMessageFile = "registration/msg.txt" 145 | return req 146 | }, 147 | inputDir: fstest.MapFS{ 148 | "registration/msg.txt": {Data: []byte(customFile)}, 149 | }, 150 | wantPresent: append([]string{customFile}, buildSummary...), 151 | wantAbsent: []string{customMessage}, 152 | }, 153 | { 154 | name: "chat_message_file, params.append false", 155 | makeReq: func() PutRequest { 156 | req := baseRequest 157 | req.Params.ChatMessageFile = "registration/msg.txt" 158 | req.Params.ChatAppendSummary = false 159 | return req 160 | }, 161 | inputDir: fstest.MapFS{ 162 | "registration/msg.txt": {Data: []byte(customFile)}, 163 | }, 164 | wantPresent: []string{customFile}, 165 | wantAbsent: append([]string{customMessage}, buildSummary...), 166 | }, 167 | { 168 | name: "chat_message and chat_message_file, all defaults", 169 | makeReq: func() PutRequest { 170 | req := baseRequest 171 | req.Params.ChatMessage = customMessage 172 | req.Params.ChatMessageFile = "registration/msg.txt" 173 | return req 174 | }, 175 | inputDir: fstest.MapFS{ 176 | "registration/msg.txt": {Data: []byte(customFile)}, 177 | }, 178 | wantPresent: append([]string{customMessage, customFile}, buildSummary...), 179 | }, 180 | { 181 | name: "chat_message and chat_message_file, params.append false", 182 | makeReq: func() PutRequest { 183 | req := baseRequest 184 | req.Params.ChatMessage = customMessage 185 | req.Params.ChatMessageFile = "registration/msg.txt" 186 | req.Params.ChatAppendSummary = false 187 | return req 188 | }, 189 | inputDir: fstest.MapFS{ 190 | "registration/msg.txt": {Data: []byte(customFile)}, 191 | }, 192 | wantPresent: []string{customMessage, customFile}, 193 | wantAbsent: buildSummary, 194 | }, 195 | } 196 | 197 | for _, tc := range testCases { 198 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 199 | } 200 | } 201 | 202 | func TestPrepareChatMessageFailure(t *testing.T) { 203 | request := PutRequest{Params: PutParams{ChatMessageFile: "foo/msg.txt"}} 204 | inputDir := fstest.MapFS{"bar/msg.txt": {Data: []byte("from-custom-file")}} 205 | 206 | _, err := prepareChatMessage(inputDir, request, "deadbeef") 207 | 208 | assert.Error(t, err, 209 | "reading chat_message_file: open foo/msg.txt: file does not exist") 210 | } 211 | 212 | func TestGChatBuildSummaryText(t *testing.T) { 213 | commit := "deadbeef" 214 | state := StatePending 215 | src := Source{ 216 | Owner: "the-owner", 217 | Repo: "the-repo", 218 | } 219 | env := Environment{ 220 | BuildName: "42", 221 | BuildJobName: "the-job", 222 | BuildPipelineName: "the-pipeline", 223 | AtcExternalUrl: "https://cogito.example", 224 | } 225 | 226 | have := gChatBuildSummaryText(commit, state, src, env) 227 | 228 | assert.Assert(t, cmp.Contains(have, "*pipeline* the-pipeline")) 229 | assert.Assert(t, cmp.Regexp(`\*job\* `, have)) 230 | assert.Assert(t, cmp.Contains(have, "*state* 🟡 pending")) 231 | assert.Assert(t, cmp.Regexp( 232 | `\*commit\* \(repo: the-owner\/the-repo\)`, 233 | have)) 234 | } 235 | 236 | func TestStateToIcon(t *testing.T) { 237 | type testCase struct { 238 | state BuildState 239 | want string 240 | } 241 | 242 | test := func(t *testing.T, tc testCase) { 243 | assert.Equal(t, decorateState(tc.state), tc.want) 244 | } 245 | 246 | testCases := []testCase{ 247 | {state: StateAbort, want: "🟤 abort"}, 248 | {state: StateError, want: "🟠 error"}, 249 | {state: StateFailure, want: "🔴 failure"}, 250 | {state: StatePending, want: "🟡 pending"}, 251 | {state: StateSuccess, want: "🟢 success"}, 252 | {state: BuildState("impossible"), want: "❓ impossible"}, 253 | } 254 | 255 | for _, tc := range testCases { 256 | t.Run(string(tc.state), func(t *testing.T) { test(t, tc) }) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /cogito/gchatsink_test.go: -------------------------------------------------------------------------------- 1 | package cogito_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | "testing/fstest" 9 | 10 | "gotest.tools/v3/assert" 11 | "gotest.tools/v3/assert/cmp" 12 | 13 | "github.com/Pix4D/cogito/cogito" 14 | "github.com/Pix4D/cogito/googlechat" 15 | "github.com/Pix4D/cogito/testhelp" 16 | ) 17 | 18 | func TestSinkGoogleChatSendSuccess(t *testing.T) { 19 | type testCase struct { 20 | name string 21 | setWebHook func(req *cogito.PutRequest, url string) 22 | } 23 | 24 | test := func(t *testing.T, tc testCase) { 25 | wantGitRef := "deadbeef" 26 | wantState := cogito.StateError // We want a state that is sent by default 27 | var message googlechat.BasicMessage 28 | reply := googlechat.MessageReply{} 29 | var URL *url.URL 30 | ts := testhelp.SpyHttpServer(&message, reply, &URL, http.StatusOK) 31 | request := basePutRequest 32 | request.Params = cogito.PutParams{State: wantState} 33 | request.Env = cogito.Environment{ 34 | BuildPipelineName: "the-test-pipeline", 35 | BuildJobName: "the-test-job", 36 | } 37 | tc.setWebHook(&request, ts.URL) 38 | assert.NilError(t, request.Source.Validate()) 39 | sink := cogito.GoogleChatSink{ 40 | Log: testhelp.MakeTestLog(), 41 | GitRef: wantGitRef, 42 | Request: request, 43 | } 44 | 45 | err := sink.Send() 46 | 47 | assert.NilError(t, err) 48 | ts.Close() // Avoid races before the following asserts. 49 | assert.Assert(t, cmp.Contains(message.Text, "*state* 🟠 error")) 50 | assert.Assert(t, cmp.Contains(message.Text, "*pipeline* the-test-pipeline")) 51 | assert.Assert(t, cmp.Contains(URL.String(), "/?threadKey=the-test-pipeline+deadbeef")) 52 | } 53 | 54 | testCases := []testCase{ 55 | { 56 | name: "default chat space", 57 | setWebHook: func(req *cogito.PutRequest, url string) { 58 | req.Source.GChatWebHook = url 59 | }, 60 | }, 61 | { 62 | name: "multiple chat spaces", 63 | setWebHook: func(req *cogito.PutRequest, url string) { 64 | req.Params.GChatWebHook = url 65 | }, 66 | }, 67 | } 68 | 69 | for _, tc := range testCases { 70 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 71 | } 72 | } 73 | 74 | func TestSinkGoogleChatDecidesNotToSendSuccess(t *testing.T) { 75 | type testCase struct { 76 | name string 77 | request cogito.PutRequest 78 | } 79 | 80 | test := func(t *testing.T, tc testCase) { 81 | sink := cogito.GoogleChatSink{ 82 | Log: testhelp.MakeTestLog(), 83 | Request: tc.request, 84 | } 85 | 86 | err := sink.Send() 87 | 88 | assert.NilError(t, err) 89 | } 90 | 91 | testCases := []testCase{ 92 | { 93 | name: "feature not enabled", 94 | request: cogito.PutRequest{ 95 | Source: cogito.Source{GChatWebHook: ""}, // empty 96 | Params: cogito.PutParams{State: cogito.StateError}, // sent by default 97 | }, 98 | }, 99 | { 100 | name: "state not in enabled states", 101 | request: cogito.PutRequest{ 102 | Source: cogito.Source{GChatWebHook: "https://cogito.example"}, 103 | Params: cogito.PutParams{State: cogito.StatePending}, // not sent by default 104 | }, 105 | }, 106 | } 107 | 108 | for _, tc := range testCases { 109 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 110 | } 111 | } 112 | 113 | func TestSinkGoogleChatSendBackendFailure(t *testing.T) { 114 | ts := httptest.NewServer( 115 | http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 116 | w.WriteHeader(http.StatusTeapot) 117 | })) 118 | request := basePutRequest 119 | request.Source.GChatWebHook = ts.URL 120 | assert.NilError(t, request.Source.Validate()) 121 | sink := cogito.GoogleChatSink{ 122 | Log: testhelp.MakeTestLog(), 123 | Request: request, 124 | } 125 | 126 | err := sink.Send() 127 | 128 | assert.ErrorContains(t, err, "GoogleChatSink: TextMessage: status: 418 I'm a teapot") 129 | ts.Close() 130 | } 131 | 132 | func TestSinkGoogleChatSendInputFailure(t *testing.T) { 133 | request := basePutRequest 134 | request.Params.ChatMessageFile = "foo/msg.txt" 135 | request.Source.GChatWebHook = "dummy-url" 136 | assert.NilError(t, request.Source.Validate()) 137 | sink := cogito.GoogleChatSink{ 138 | Log: testhelp.MakeTestLog(), 139 | InputDir: fstest.MapFS{"bar/msg.txt": {Data: []byte("from-custom-file")}}, 140 | Request: request, 141 | } 142 | 143 | err := sink.Send() 144 | 145 | assert.ErrorContains(t, err, "GoogleChatSink: reading chat_message_file: open") 146 | } 147 | -------------------------------------------------------------------------------- /cogito/get.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | ) 9 | 10 | // Get implements the "get" step (the "in" executable). 11 | // For the Cogito resource, this is a no-op. 12 | // 13 | // From https://concourse-ci.org/implementing-resource-types.html#resource-in: 14 | // 15 | // The program is passed a destination directory as command line argument $1, and is 16 | // given on stdin the configured source and a version of the resource to fetch. 17 | // 18 | // The program must fetch the resource and place it in the given directory. 19 | // 20 | // If the desired resource version is unavailable (for example, if it was deleted), the 21 | // script must exit with error. 22 | // 23 | // The program must emit a JSON object containing the fetched version, and may emit 24 | // metadata as a list of key-value pairs. 25 | // This data is intended for public consumption and will be shown on the build page. 26 | func Get(log *slog.Logger, input []byte, out io.Writer, args []string) error { 27 | log = log.With("name", "cogito.get") 28 | 29 | request, err := NewGetRequest(input) 30 | if err != nil { 31 | return err 32 | } 33 | log.Debug("parsed get request", 34 | "source", request.Source, 35 | "version", request.Version, 36 | "environment", request.Env, 37 | "args", args) 38 | 39 | if request.Version.Ref == "" { 40 | return fmt.Errorf("get: empty 'version' field") 41 | } 42 | 43 | // args[0] contains the path to a Concourse volume and a normal resource would fetch 44 | // and put there the requested version of the resource. 45 | // In this resource we do nothing, but we still check for protocol conformance. 46 | if len(args) == 0 { 47 | return fmt.Errorf("get: arguments: missing output directory") 48 | } 49 | log.Debug("", "output-directory", args[0]) 50 | 51 | // Following the protocol for get, we return the same version as the requested one. 52 | output := Output{Version: request.Version} 53 | enc := json.NewEncoder(out) 54 | if err := enc.Encode(output); err != nil { 55 | return fmt.Errorf("get: preparing output: %s", err) 56 | } 57 | 58 | log.Debug("success", "output.version", output.Version, 59 | "output.metadata", output.Metadata) 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /cogito/get_test.go: -------------------------------------------------------------------------------- 1 | package cogito_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | 8 | "gotest.tools/v3/assert" 9 | 10 | "github.com/Pix4D/cogito/cogito" 11 | "github.com/Pix4D/cogito/github" 12 | "github.com/Pix4D/cogito/testhelp" 13 | ) 14 | 15 | func TestGetSuccess(t *testing.T) { 16 | type testCase struct { 17 | name string 18 | request cogito.GetRequest 19 | wantOut cogito.Output 20 | } 21 | 22 | test := func(t *testing.T, tc testCase) { 23 | in := testhelp.ToJSON(t, tc.request) 24 | var out bytes.Buffer 25 | log := testhelp.MakeTestLog() 26 | 27 | err := cogito.Get(log, in, &out, []string{"dummy-dir"}) 28 | 29 | assert.NilError(t, err) 30 | var have cogito.Output 31 | testhelp.FromJSON(t, out.Bytes(), &have) 32 | assert.DeepEqual(t, have, tc.wantOut) 33 | } 34 | 35 | baseGithubSource := cogito.Source{ 36 | Owner: "the-owner", 37 | Repo: "the-repo", 38 | AccessToken: "the-token", 39 | } 40 | 41 | testCases := []testCase{ 42 | { 43 | name: "returns requested version", 44 | request: cogito.GetRequest{ 45 | Source: baseGithubSource, 46 | Version: cogito.Version{Ref: "banana"}, 47 | }, 48 | wantOut: cogito.Output{Version: cogito.Version{Ref: "banana"}}, 49 | }, 50 | } 51 | 52 | for _, tc := range testCases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | test(t, tc) 55 | }) 56 | } 57 | } 58 | 59 | func TestGetFailure(t *testing.T) { 60 | type testCase struct { 61 | name string 62 | args []string 63 | source cogito.Source // will be embedded in cogito.GetRequest 64 | version cogito.Version // will be embedded in cogito.GetRequest 65 | writer io.Writer 66 | wantErr string 67 | } 68 | 69 | test := func(t *testing.T, tc testCase) { 70 | assert.Assert(t, tc.wantErr != "") 71 | in := testhelp.ToJSON(t, 72 | cogito.GetRequest{ 73 | Source: tc.source, 74 | Version: tc.version, 75 | }) 76 | log := testhelp.MakeTestLog() 77 | 78 | err := cogito.Get(log, in, tc.writer, tc.args) 79 | 80 | assert.Error(t, err, tc.wantErr) 81 | } 82 | 83 | baseGithubSource := cogito.Source{ 84 | Owner: "the-owner", 85 | Repo: "the-repo", 86 | AccessToken: "the-token", 87 | } 88 | 89 | testCases := []testCase{ 90 | { 91 | name: "user validation failure: missing access token or github_app", 92 | source: cogito.Source{}, 93 | writer: io.Discard, 94 | wantErr: "get: source: one of access_token or github_app must be specified", 95 | }, 96 | { 97 | name: "user validation failure: both access_key and github_app are set", 98 | source: cogito.Source{ 99 | AccessToken: "dummy-token", 100 | GitHubApp: github.GitHubApp{ClientId: "client-id"}, 101 | }, 102 | writer: io.Discard, 103 | wantErr: "get: source: cannot specify both github_app and access_token", 104 | }, 105 | { 106 | name: "user validation failure: missing keys", 107 | source: cogito.Source{AccessToken: "dummy-token"}, 108 | writer: io.Discard, 109 | wantErr: "get: source: missing keys: owner, repo", 110 | }, 111 | { 112 | name: "concourse validation failure: empty version field", 113 | source: baseGithubSource, 114 | writer: io.Discard, 115 | wantErr: "get: empty 'version' field", 116 | }, 117 | { 118 | name: "concourse validation failure: missing output directory", 119 | source: baseGithubSource, 120 | version: cogito.Version{Ref: "dummy"}, 121 | writer: io.Discard, 122 | wantErr: "get: arguments: missing output directory", 123 | }, 124 | { 125 | name: "system write error", 126 | args: []string{"dummy-dir"}, 127 | source: baseGithubSource, 128 | version: cogito.Version{Ref: "dummy"}, 129 | writer: &testhelp.FailingWriter{}, 130 | wantErr: "get: preparing output: test write error", 131 | }, 132 | { 133 | name: "user missing gchat webhook", 134 | source: cogito.Source{Sinks: []string{"gchat"}}, 135 | version: cogito.Version{Ref: "dummy"}, 136 | writer: &testhelp.FailingWriter{}, 137 | wantErr: "get: source: missing keys: gchat_webhook", 138 | }, 139 | { 140 | name: "user validation failure: wrong sink key", 141 | source: cogito.Source{Sinks: []string{"ghost", "gchat"}}, 142 | writer: io.Discard, 143 | wantErr: "get: source: invalid sink(s): [ghost]", 144 | }, 145 | } 146 | 147 | for _, tc := range testCases { 148 | t.Run(tc.name, func(t *testing.T) { 149 | test(t, tc) 150 | }) 151 | } 152 | } 153 | 154 | func TestGetNonEmptyParamsFailure(t *testing.T) { 155 | in := []byte(` 156 | { 157 | "source": {}, 158 | "params": {"pizza": "margherita"} 159 | }`) 160 | wantErr := `get: parsing request: json: unknown field "params"` 161 | 162 | err := cogito.Get(testhelp.MakeTestLog(), in, io.Discard, []string{}) 163 | 164 | assert.Error(t, err, wantErr) 165 | } 166 | -------------------------------------------------------------------------------- /cogito/ghcommitsink.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/Pix4D/cogito/github" 10 | "github.com/Pix4D/cogito/retry" 11 | ) 12 | 13 | const ( 14 | // retryUpTo is the total maximum duration of the retries. 15 | retryUpTo = 15 * time.Minute 16 | 17 | // retryFirstDelay is duration of the first backoff. 18 | retryFirstDelay = 2 * time.Second 19 | 20 | // retryBackoffLimit is the upper bound duration of a backoff. 21 | // That is, with an exponential backoff and a retryFirstDelay = 2s, the sequence will be: 22 | // 2s 4s 8s 16s 32s 60s ... 60s, until reaching a cumulative delay of retryUpTo. 23 | retryBackoffLimit = 1 * time.Minute 24 | ) 25 | 26 | // GitHubCommitStatusSink is an implementation of [Sinker] for the Cogito resource. 27 | type GitHubCommitStatusSink struct { 28 | Log *slog.Logger 29 | GitRef string 30 | Request PutRequest 31 | } 32 | 33 | // Send sets the build status via the GitHub Commit status API endpoint. 34 | func (sink GitHubCommitStatusSink) Send() error { 35 | sink.Log.Debug("send: started") 36 | defer sink.Log.Debug("send: finished") 37 | 38 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 39 | defer cancel() 40 | 41 | httpClient := &http.Client{} 42 | 43 | ghState := ghAdaptState(sink.Request.Params.State) 44 | buildURL := concourseBuildURL(sink.Request.Env) 45 | context := ghMakeContext(sink.Request) 46 | server := github.ApiRoot(sink.Request.Source.GhHostname) 47 | 48 | token := sink.Request.Source.AccessToken 49 | // if access token is not configured, we are using github_app 50 | // so we must generate the installation token 51 | if token == "" { 52 | installationToken, err := github.GenerateInstallationToken(ctx, httpClient, server, sink.Request.Source.GitHubApp) 53 | if err != nil { 54 | return err 55 | } 56 | token = installationToken 57 | } 58 | 59 | target := &github.Target{ 60 | Client: httpClient, 61 | Server: server, 62 | Retry: retry.Retry{ 63 | FirstDelay: retryFirstDelay, 64 | BackoffLimit: retryBackoffLimit, 65 | UpTo: retryUpTo, 66 | Log: sink.Log, 67 | }, 68 | } 69 | commitStatus := github.NewCommitStatus(target, token, 70 | sink.Request.Source.Owner, sink.Request.Source.Repo, context, sink.Log) 71 | description := "Build " + sink.Request.Env.BuildName 72 | 73 | sink.Log.Debug("posting to GitHub Commit Status API", 74 | "state", ghState, "owner", sink.Request.Source.Owner, 75 | "repo", sink.Request.Source.Repo, "git-ref", sink.GitRef, 76 | "context", context, "buildURL", buildURL, "description", description) 77 | if sink.Request.Source.OmitTargetURL { 78 | buildURL = "" 79 | } 80 | if err := commitStatus.Add(ctx, sink.GitRef, ghState, buildURL, description); err != nil { 81 | return err 82 | } 83 | sink.Log.Info("commit status posted successfully", 84 | "state", ghState, "git-ref", sink.GitRef[0:9]) 85 | 86 | return nil 87 | } 88 | 89 | // The states allowed by cogito are more than the states allowed by the GitHub Commit 90 | // status API. Adapt accordingly. 91 | func ghAdaptState(state BuildState) string { 92 | if state == StateAbort { 93 | return string(StateError) 94 | } 95 | return string(state) 96 | } 97 | 98 | // ghMakeContext returns the "context" parameter of the GitHub Commit Status API, based 99 | // on the fields of request. 100 | func ghMakeContext(request PutRequest) string { 101 | var context string 102 | if request.Source.ContextPrefix != "" { 103 | context = request.Source.ContextPrefix + "/" 104 | } 105 | if request.Params.Context != "" { 106 | context += request.Params.Context 107 | } else { 108 | context += request.Env.BuildJobName 109 | } 110 | return context 111 | } 112 | -------------------------------------------------------------------------------- /cogito/ghcommitsink_private_test.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestGhMakeContext(t *testing.T) { 10 | type testCase struct { 11 | name string 12 | request PutRequest 13 | wantContext string 14 | } 15 | 16 | test := func(t *testing.T, tc testCase) { 17 | have := ghMakeContext(tc.request) 18 | 19 | assert.Equal(t, have, tc.wantContext) 20 | } 21 | 22 | testCases := []testCase{ 23 | { 24 | name: "default: context taken from job name", 25 | request: PutRequest{ 26 | Env: Environment{BuildJobName: "the-job"}, 27 | }, 28 | wantContext: "the-job", 29 | }, 30 | { 31 | name: "context_prefix", 32 | request: PutRequest{ 33 | Source: Source{ContextPrefix: "the-prefix"}, 34 | Env: Environment{BuildJobName: "the-job"}, 35 | }, 36 | wantContext: "the-prefix/the-job", 37 | }, 38 | { 39 | name: "explicit context overrides job name", 40 | request: PutRequest{ 41 | Params: PutParams{Context: "the-context"}, 42 | Env: Environment{BuildJobName: "the-job"}, 43 | }, 44 | wantContext: "the-context", 45 | }, 46 | { 47 | name: "prefix and override", 48 | request: PutRequest{ 49 | Source: Source{ContextPrefix: "the-prefix"}, 50 | Params: PutParams{Context: "the-context"}, 51 | Env: Environment{BuildJobName: "the-job"}, 52 | }, 53 | wantContext: "the-prefix/the-context", 54 | }, 55 | } 56 | 57 | for _, tc := range testCases { 58 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 59 | } 60 | } 61 | 62 | func TestGhAdaptState(t *testing.T) { 63 | type testCase struct { 64 | name string 65 | state BuildState 66 | want BuildState 67 | } 68 | 69 | test := func(t *testing.T, tc testCase) { 70 | have := ghAdaptState(tc.state) 71 | 72 | assert.Equal(t, BuildState(have), tc.want) 73 | } 74 | 75 | testCases := []testCase{ 76 | { 77 | name: "no conversion", 78 | state: StatePending, 79 | want: StatePending, 80 | }, 81 | { 82 | name: "abort converted to error", 83 | state: StateAbort, 84 | want: StateError, 85 | }, 86 | } 87 | 88 | for _, tc := range testCases { 89 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cogito/ghcommitsink_test.go: -------------------------------------------------------------------------------- 1 | package cogito_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "path" 10 | "testing" 11 | 12 | "gotest.tools/v3/assert" 13 | 14 | "github.com/Pix4D/cogito/cogito" 15 | "github.com/Pix4D/cogito/github" 16 | "github.com/Pix4D/cogito/testhelp" 17 | ) 18 | 19 | func TestSinkGitHubCommitStatusSendSuccess(t *testing.T) { 20 | wantGitRef := "deadbeefdeadbeef" 21 | wantState := cogito.StatePending 22 | jobName := "the-job" 23 | wantContext := jobName 24 | var ghReq github.AddRequest 25 | var URL *url.URL 26 | ts := testhelp.SpyHttpServer(&ghReq, nil, &URL, http.StatusCreated) 27 | gitHubSpyURL, err := url.Parse(ts.URL) 28 | assert.NilError(t, err, "error parsing SpyHttpServer URL: %s", err) 29 | sink := cogito.GitHubCommitStatusSink{ 30 | Log: testhelp.MakeTestLog(), 31 | GitRef: wantGitRef, 32 | Request: cogito.PutRequest{ 33 | Source: cogito.Source{GhHostname: gitHubSpyURL.Host, AccessToken: "dummy-token"}, 34 | Params: cogito.PutParams{State: wantState}, 35 | Env: cogito.Environment{BuildJobName: jobName}, 36 | }, 37 | } 38 | 39 | err = sink.Send() 40 | 41 | assert.NilError(t, err) 42 | ts.Close() // Avoid races before the following asserts. 43 | assert.Equal(t, path.Base(URL.Path), wantGitRef) 44 | assert.Equal(t, ghReq.State, string(wantState)) 45 | assert.Equal(t, ghReq.Context, wantContext) 46 | } 47 | 48 | func TestSinkGitHubCommitStatusSendGhAppSuccess(t *testing.T) { 49 | wantGitRef := "deadbeefdeadbeef" 50 | wantState := cogito.StatePending 51 | jobName := "the-job" 52 | wantContext := jobName 53 | var ghReq github.AddRequest 54 | 55 | handler := func(w http.ResponseWriter, r *http.Request) { 56 | if r.URL.String() == "/repos/statuses/deadbeefdeadbeef" { 57 | dec := json.NewDecoder(r.Body) 58 | if err := dec.Decode(&ghReq); err != nil { 59 | w.WriteHeader(http.StatusTeapot) 60 | fmt.Fprintln(w, "test: decoding request:", err) 61 | return 62 | } 63 | } 64 | 65 | w.WriteHeader(http.StatusCreated) 66 | if r.URL.String() == "/app/installations/12345/access_tokens" { 67 | fmt.Fprintln(w, `{"token": "dummy_installation_token"}`) 68 | return 69 | } 70 | } 71 | ts := httptest.NewServer(http.HandlerFunc(handler)) 72 | defer ts.Close() 73 | 74 | gitHubSpyURL, err := url.Parse(ts.URL) 75 | assert.NilError(t, err, "error parsing SpyHttpServer URL: %s", err) 76 | 77 | privateKey, err := testhelp.GeneratePrivateKey(t, 2048) 78 | assert.NilError(t, err) 79 | 80 | sink := cogito.GitHubCommitStatusSink{ 81 | Log: testhelp.MakeTestLog(), 82 | GitRef: wantGitRef, 83 | Request: cogito.PutRequest{ 84 | Source: cogito.Source{ 85 | GhHostname: gitHubSpyURL.Host, 86 | GitHubApp: github.GitHubApp{ 87 | ClientId: "client-id", 88 | InstallationId: 12345, 89 | PrivateKey: string(testhelp.EncodePrivateKeyToPEM(privateKey)), 90 | }, 91 | }, 92 | Params: cogito.PutParams{State: wantState}, 93 | Env: cogito.Environment{BuildJobName: jobName}, 94 | }, 95 | } 96 | 97 | err = sink.Send() 98 | 99 | assert.NilError(t, err) 100 | assert.Equal(t, ghReq.State, string(wantState)) 101 | assert.Equal(t, ghReq.Context, wantContext) 102 | } 103 | 104 | func TestSinkGitHubCommitStatusSendFailure(t *testing.T) { 105 | ts := httptest.NewServer( 106 | http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 107 | w.WriteHeader(http.StatusTeapot) 108 | })) 109 | gitHubSpyURL, err := url.Parse(ts.URL) 110 | assert.NilError(t, err, "error parsing SpyHttpServer URL: %s", err) 111 | defer ts.Close() 112 | sink := cogito.GitHubCommitStatusSink{ 113 | Log: testhelp.MakeTestLog(), 114 | GitRef: "deadbeefdeadbeef", 115 | Request: cogito.PutRequest{ 116 | Source: cogito.Source{GhHostname: gitHubSpyURL.Host, AccessToken: "dummy-token"}, 117 | Params: cogito.PutParams{State: cogito.StatePending}, 118 | }, 119 | } 120 | 121 | err = sink.Send() 122 | 123 | assert.ErrorContains(t, err, 124 | `failed to add state "pending" for commit deadbee: 418 I'm a teapot`) 125 | } 126 | -------------------------------------------------------------------------------- /cogito/protocol_test.go: -------------------------------------------------------------------------------- 1 | package cogito_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | "testing" 10 | 11 | "gotest.tools/v3/assert" 12 | "gotest.tools/v3/assert/cmp" 13 | 14 | "github.com/Pix4D/cogito/cogito" 15 | "github.com/Pix4D/cogito/github" 16 | "github.com/Pix4D/cogito/testhelp" 17 | ) 18 | 19 | func TestSourceValidationSuccess(t *testing.T) { 20 | type testCase struct { 21 | name string 22 | mkSource func() cogito.Source 23 | } 24 | 25 | test := func(t *testing.T, tc testCase) { 26 | source := tc.mkSource() 27 | err := source.Validate() 28 | 29 | assert.NilError(t, err) 30 | } 31 | 32 | baseGithubSource := cogito.Source{ 33 | Owner: "the-owner", 34 | Repo: "the-repo", 35 | AccessToken: "the-token", 36 | } 37 | 38 | testCases := []testCase{ 39 | { 40 | name: "only mandatory keys", 41 | mkSource: func() cogito.Source { return baseGithubSource }, 42 | }, 43 | { 44 | name: "explicit log_level", 45 | mkSource: func() cogito.Source { 46 | source := baseGithubSource 47 | source.LogLevel = "debug" 48 | return source 49 | }, 50 | }, 51 | { 52 | name: "git source: github_hostname: httptest server hostname", 53 | mkSource: func() cogito.Source { 54 | source := baseGithubSource 55 | source.GhHostname = "127.0.0.1:1234" 56 | return source 57 | }, 58 | }, 59 | { 60 | name: "git source: github_hostname: github.foo.com", 61 | mkSource: func() cogito.Source { 62 | source := baseGithubSource 63 | source.GhHostname = "github.foo.com" 64 | return source 65 | }, 66 | }, 67 | { 68 | name: "git source: github app", 69 | mkSource: func() cogito.Source { 70 | return cogito.Source{ 71 | Owner: "the-owner", 72 | Repo: "the-repo", 73 | GitHubApp: github.GitHubApp{ 74 | ClientId: "client-id-key", 75 | InstallationId: 12345, 76 | PrivateKey: "private-ssh-key", 77 | }, 78 | } 79 | }, 80 | }, 81 | } 82 | 83 | for _, tc := range testCases { 84 | t.Run(tc.name, func(t *testing.T) { 85 | test(t, tc) 86 | }) 87 | } 88 | } 89 | 90 | func TestSourceValidationFailure(t *testing.T) { 91 | type testCase struct { 92 | name string 93 | source cogito.Source 94 | wantErr string 95 | } 96 | 97 | test := func(t *testing.T, tc testCase) { 98 | assert.Assert(t, tc.wantErr != "") 99 | 100 | err := tc.source.Validate() 101 | 102 | assert.Error(t, err, tc.wantErr) 103 | } 104 | 105 | testCases := []testCase{ 106 | { 107 | name: "access_token and github_app both missing", 108 | source: cogito.Source{}, 109 | wantErr: "source: one of access_token or github_app must be specified", 110 | }, 111 | { 112 | name: "both access_key and github_app are set", 113 | source: cogito.Source{ 114 | AccessToken: "dummy-token", 115 | GitHubApp: github.GitHubApp{ClientId: "client-id"}, 116 | }, 117 | wantErr: "source: cannot specify both github_app and access_token", 118 | }, 119 | { 120 | name: "missing mandatory git source keys", 121 | source: cogito.Source{AccessToken: "dummy-token"}, 122 | wantErr: "source: missing keys: owner, repo", 123 | }, 124 | { 125 | name: "missing mandatory git source keys for github app: client-id", 126 | source: cogito.Source{ 127 | Owner: "the-owner", 128 | Repo: "the-repo", 129 | GitHubApp: github.GitHubApp{ 130 | InstallationId: 1234, 131 | PrivateKey: "private-rsa-key", 132 | }, 133 | }, 134 | wantErr: "source: missing keys: github_app.client_id", 135 | }, 136 | { 137 | name: "missing mandatory git source keys for github app: private key", 138 | source: cogito.Source{ 139 | Owner: "the-owner", 140 | Repo: "the-repo", 141 | GitHubApp: github.GitHubApp{ 142 | ClientId: "client-id", 143 | }, 144 | }, 145 | wantErr: "source: missing keys: github_app.installation_id, github_app.private_key", 146 | }, 147 | { 148 | name: "missing mandatory gchat source key", 149 | source: cogito.Source{Sinks: []string{"gchat"}}, 150 | wantErr: "source: missing keys: gchat_webhook", 151 | }, 152 | { 153 | name: "invalid sink source key", 154 | source: cogito.Source{Sinks: []string{"gchat", "ghost"}}, 155 | wantErr: "source: invalid sink(s): [ghost]", 156 | }, 157 | { 158 | name: "multiple invalid sink source key", 159 | source: cogito.Source{Sinks: []string{"coffee", "shop"}}, 160 | wantErr: "source: invalid sink(s): [coffee shop]", 161 | }, 162 | { 163 | name: "multiple mixed invalid/valid sink source key", 164 | source: cogito.Source{ 165 | Sinks: []string{"coffee", "shop", "closed", "gchat", "github"}, 166 | GChatWebHook: "sensitive-gchat-webhook", 167 | Owner: "the-owner", 168 | Repo: "the-repo", 169 | AccessToken: "the-token", 170 | }, 171 | wantErr: "source: invalid sink(s): [closed coffee shop]", 172 | }, 173 | { 174 | name: "invalid github_hostname: configured with schema, the path or both", 175 | source: cogito.Source{ 176 | GhHostname: "https://github.foo.com/api/v3/", 177 | Owner: "the-owner", 178 | Repo: "the-repo", 179 | AccessToken: "the-token", 180 | }, 181 | wantErr: "source: invalid github_api_hostname: https://github.foo.com/api/v3/. Don't configure the schema or the path", 182 | }, 183 | } 184 | 185 | for _, tc := range testCases { 186 | t.Run(tc.name, func(t *testing.T) { 187 | test(t, tc) 188 | }) 189 | } 190 | } 191 | 192 | // The majority of tests for failure are done in TestSourceValidationFailure, which limits 193 | // the input since it uses a struct. Thus, we also test with some raw JSON input text. 194 | func TestSourceParseRawFailure(t *testing.T) { 195 | type testCase struct { 196 | name string 197 | input string 198 | wantErr string 199 | } 200 | 201 | test := func(t *testing.T, tc testCase) { 202 | assert.Assert(t, tc.wantErr != "") 203 | in := strings.NewReader(tc.input) 204 | var source cogito.Source 205 | dec := json.NewDecoder(in) 206 | dec.DisallowUnknownFields() 207 | 208 | err := dec.Decode(&source) 209 | 210 | assert.Error(t, err, tc.wantErr) 211 | } 212 | 213 | testCases := []testCase{ 214 | { 215 | name: "empty input", 216 | input: ``, 217 | wantErr: `EOF`, 218 | }, 219 | { 220 | name: "malformed input", 221 | input: `pizza`, 222 | wantErr: `invalid character 'p' looking for beginning of value`, 223 | }, 224 | { 225 | name: "JSON types validation is automatic (since Go is statically typed)", 226 | input: ` 227 | { 228 | "owner": 123 229 | }`, 230 | wantErr: `json: cannot unmarshal number into Go struct field source.owner of type string`, 231 | }, 232 | { 233 | name: "Unknown fields are caught automatically by the JSON decoder", 234 | input: ` 235 | { 236 | "owner": "the-owner", 237 | "repo": "the-repo", 238 | "access_token": "the-token", 239 | "hello": "I am an unknown key" 240 | }`, 241 | wantErr: `json: unknown field "hello"`, 242 | }, 243 | } 244 | 245 | for _, tc := range testCases { 246 | t.Run(tc.name, func(t *testing.T) { 247 | test(t, tc) 248 | }) 249 | } 250 | } 251 | 252 | func TestSourcePrintLogRedaction(t *testing.T) { 253 | source := cogito.Source{ 254 | Owner: "the-owner", 255 | Repo: "the-repo", 256 | GhHostname: "github.com", 257 | AccessToken: "sensitive-the-access-token", 258 | GitHubApp: github.GitHubApp{ClientId: "client-id", InstallationId: 1234, PrivateKey: "sensitive-private-rsa-key"}, 259 | GChatWebHook: "sensitive-gchat-webhook", 260 | LogLevel: "debug", 261 | ContextPrefix: "the-prefix", 262 | ChatAppendSummary: true, 263 | ChatNotifyOnStates: []cogito.BuildState{cogito.StateSuccess, cogito.StateFailure}, 264 | } 265 | 266 | t.Run("fmt.Print redacts fields", func(t *testing.T) { 267 | want := `owner: the-owner 268 | repo: the-repo 269 | github_hostname: github.com 270 | access_token: ***REDACTED*** 271 | gchat_webhook: ***REDACTED*** 272 | github_app.client_id: client-id 273 | github_app.installation_id: 1234 274 | github_app.private_key: ***REDACTED*** 275 | log_level: debug 276 | context_prefix: the-prefix 277 | omit_target_url: false 278 | chat_append_summary: true 279 | chat_notify_on_states: [success failure] 280 | sinks: []` 281 | 282 | have := fmt.Sprint(source) 283 | 284 | assert.Equal(t, have, want) 285 | }) 286 | // Trailing spaces here are needed. 287 | t.Run("empty fields are not marked as redacted", func(t *testing.T) { 288 | input := cogito.Source{ 289 | Owner: "the-owner", 290 | } 291 | want := `owner: the-owner 292 | repo: 293 | github_hostname: 294 | access_token: 295 | gchat_webhook: 296 | github_app.client_id: 297 | github_app.installation_id: 0 298 | github_app.private_key: 299 | log_level: 300 | context_prefix: 301 | omit_target_url: false 302 | chat_append_summary: false 303 | chat_notify_on_states: [] 304 | sinks: []` 305 | 306 | have := fmt.Sprint(input) 307 | 308 | assert.Equal(t, have, want) 309 | }) 310 | 311 | t.Run("slog redacts fields", func(t *testing.T) { 312 | var logBuf bytes.Buffer 313 | log := slog.New(slog.NewTextHandler(&logBuf, 314 | &slog.HandlerOptions{ReplaceAttr: testhelp.RemoveTime})) 315 | 316 | log.Info("log test", "source", source) 317 | have := logBuf.String() 318 | 319 | assert.Assert(t, cmp.Contains(have, "access_token: ***REDACTED***")) 320 | assert.Assert(t, cmp.Contains(have, "gchat_webhook: ***REDACTED***")) 321 | assert.Assert(t, !strings.Contains(have, "sensitive")) 322 | }) 323 | } 324 | 325 | func TestPutParamsPrintLogRedaction(t *testing.T) { 326 | params := cogito.PutParams{ 327 | State: cogito.StatePending, 328 | Context: "johnny", 329 | ChatMessage: "stecchino", 330 | ChatMessageFile: "dir/msg.txt", 331 | GChatWebHook: "sensitive-gchat-webhook", 332 | Sinks: []string{"gchat", "github"}, 333 | } 334 | 335 | t.Run("fmt.Print redacts fields", func(t *testing.T) { 336 | want := `state: pending 337 | context: johnny 338 | chat_message: stecchino 339 | chat_message_file: dir/msg.txt 340 | chat_append_summary: false 341 | gchat_webhook: ***REDACTED*** 342 | sinks: [gchat github]` 343 | 344 | have := fmt.Sprint(params) 345 | 346 | assert.Equal(t, have, want) 347 | }) 348 | 349 | t.Run("empty fields are not marked as redacted", func(t *testing.T) { 350 | input := cogito.PutParams{ 351 | State: cogito.StateFailure, 352 | } 353 | // Trailing spaces here are needed. 354 | want := `state: failure 355 | context: 356 | chat_message: 357 | chat_message_file: 358 | chat_append_summary: false 359 | gchat_webhook: 360 | sinks: []` 361 | 362 | have := fmt.Sprint(input) 363 | 364 | assert.Equal(t, have, want) 365 | }) 366 | 367 | t.Run("slog redacts fields", func(t *testing.T) { 368 | var logBuf bytes.Buffer 369 | log := slog.New(slog.NewTextHandler(&logBuf, 370 | &slog.HandlerOptions{ReplaceAttr: testhelp.RemoveTime})) 371 | 372 | log.Info("log test", "params", params) 373 | have := logBuf.String() 374 | 375 | assert.Assert(t, cmp.Contains(have, "gchat_webhook: ***REDACTED***")) 376 | assert.Assert(t, !strings.Contains(have, "sensitive")) 377 | }) 378 | } 379 | 380 | func TestVersion_String(t *testing.T) { 381 | version := cogito.Version{Ref: "pizza"} 382 | 383 | have := fmt.Sprint(version) 384 | 385 | assert.Equal(t, have, "ref: pizza") 386 | } 387 | 388 | func TestEnvironment(t *testing.T) { 389 | t.Setenv("BUILD_NAME", "banana-mango") 390 | env := cogito.Environment{} 391 | 392 | env.Fill() 393 | 394 | have := fmt.Sprint(env) 395 | assert.Assert(t, strings.Contains(have, "banana-mango"), "\n%s", have) 396 | } 397 | 398 | func TestBuildStateUnmarshalJSONSuccess(t *testing.T) { 399 | var state cogito.BuildState 400 | 401 | err := state.UnmarshalJSON([]byte(`"pending"`)) 402 | 403 | assert.NilError(t, err) 404 | assert.Equal(t, state, cogito.StatePending) 405 | } 406 | 407 | func TestBuildStateUnmarshalJSONFailure(t *testing.T) { 408 | type testCase struct { 409 | name string 410 | data []byte 411 | wantErr string 412 | } 413 | 414 | test := func(t *testing.T, tc testCase) { 415 | var state cogito.BuildState 416 | 417 | assert.Error(t, state.UnmarshalJSON(tc.data), tc.wantErr) 418 | } 419 | 420 | testCases := []testCase{ 421 | { 422 | name: "no input", 423 | data: nil, 424 | wantErr: "unexpected end of JSON input", 425 | }, 426 | { 427 | name: "", 428 | data: []byte(`"pizza"`), 429 | wantErr: "invalid build state: pizza", 430 | }, 431 | } 432 | 433 | for _, tc := range testCases { 434 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /cogito/put.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "strings" 8 | ) 9 | 10 | // Putter represents the put step of a Concourse resource. 11 | // Note: The methods will be called in the same order as they are listed here. 12 | type Putter interface { 13 | // LoadConfiguration parses the resource source configuration and put params. 14 | LoadConfiguration(input []byte, args []string) error 15 | // ProcessInputDir validates and extract the needed information from the "put input". 16 | ProcessInputDir() error 17 | // Sinks return the list of configured sinks. 18 | Sinks() []Sinker 19 | // Output emits the version and metadata required by the Concourse protocol. 20 | Output(out io.Writer) error 21 | } 22 | 23 | // Sinker represents a sink: an endpoint to send a message. 24 | type Sinker interface { 25 | // Send posts the information extracted by the Putter to a specific sink. 26 | Send() error 27 | } 28 | 29 | // Put implements the "put" step (the "out" executable). 30 | // 31 | // From https://concourse-ci.org/implementing-resource-types.html#resource-out: 32 | // 33 | // The out script is passed a path to the directory containing the build's full set of 34 | // inputs as command line argument $1, and is given on stdin the configured params and 35 | // the resource's source configuration. 36 | // 37 | // The script must emit the resulting version of the resource. 38 | // 39 | // Additionally, the script may emit metadata as a list of key-value pairs. This data is 40 | // intended for public consumption and will make it upstream, intended to be shown on the 41 | // build's page. 42 | func Put(log *slog.Logger, input []byte, out io.Writer, args []string, putter Putter) error { 43 | if err := putter.LoadConfiguration(input, args); err != nil { 44 | return fmt.Errorf("put: %s", err) 45 | } 46 | 47 | if err := putter.ProcessInputDir(); err != nil { 48 | return fmt.Errorf("put: %s", err) 49 | } 50 | 51 | var sinkErrors []error 52 | sinks := putter.Sinks() 53 | 54 | // We invoke all the sinks and keep going also if some of them return an error. 55 | for _, sink := range sinks { 56 | if err := sink.Send(); err != nil { 57 | sinkErrors = append(sinkErrors, err) 58 | } 59 | } 60 | if len(sinkErrors) > 0 { 61 | return fmt.Errorf("put: %s", multiErrString(sinkErrors)) 62 | } 63 | 64 | if err := putter.Output(out); err != nil { 65 | return fmt.Errorf("put: %s", err) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // multiErrString takes a slice of errors and returns a formatted string. 72 | func multiErrString(errs []error) string { 73 | if len(errs) == 1 { 74 | return errs[0].Error() 75 | } 76 | bld := new(strings.Builder) 77 | bld.WriteString("multiple errors:") 78 | for _, err := range errs { 79 | bld.WriteString("\n\t") 80 | bld.WriteString(err.Error()) 81 | } 82 | return bld.String() 83 | } 84 | -------------------------------------------------------------------------------- /cogito/putter.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/url" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/sasbury/mini" 15 | 16 | "github.com/Pix4D/cogito/github" 17 | "github.com/Pix4D/cogito/sets" 18 | ) 19 | 20 | // ProdPutter is an implementation of a [Putter] for the Cogito resource. 21 | // Use [NewPutter] to create an instance. 22 | type ProdPutter struct { 23 | Request PutRequest 24 | InputDir string 25 | // Cogito specific fields. 26 | log *slog.Logger 27 | gitRef string 28 | } 29 | 30 | // NewPutter returns a Cogito ProdPutter. 31 | func NewPutter(log *slog.Logger) *ProdPutter { 32 | return &ProdPutter{ 33 | log: log.With("name", "cogito.put"), 34 | } 35 | } 36 | 37 | func (putter *ProdPutter) LoadConfiguration(input []byte, args []string) error { 38 | request, err := NewPutRequest(input) 39 | if err != nil { 40 | return err 41 | } 42 | putter.Request = request 43 | putter.log.Debug("parsed put request", 44 | "source", putter.Request.Source, 45 | "params", putter.Request.Params, 46 | "environment", putter.Request.Env, 47 | "args", args) 48 | 49 | sourceSinks := putter.Request.Source.Sinks 50 | putParamsSinks := putter.Request.Params.Sinks 51 | 52 | // Validate optional sinks configuration. 53 | _, err = MergeAndValidateSinks(sourceSinks, putParamsSinks) 54 | if err != nil { 55 | return fmt.Errorf("put: arguments: unsupported sink(s): %w", err) 56 | } 57 | 58 | // args[0] contains the path to a directory containing all the "put inputs". 59 | if len(args) == 0 { 60 | return fmt.Errorf("put: concourse resource protocol violation: missing input directory") 61 | } 62 | putter.InputDir = args[0] 63 | putter.log.Debug("", "input-directory", putter.InputDir) 64 | buildState := putter.Request.Params.State 65 | putter.log.Debug("", "state", buildState) 66 | 67 | return nil 68 | } 69 | 70 | func (putter *ProdPutter) ProcessInputDir() error { 71 | // putter.InputDir, corresponding to key "put:inputs:", may contain 0, 1 or 2 dirs. 72 | // If it contains zero, Cogito addresses only a supported chat system (custom sinks configured). 73 | // If it contains one, it could be the git repo or the directory containing the chat message: 74 | // in the first case we support autodiscovery by not requiring to name it, we know 75 | // that it should be the git repo. 76 | // If on the other hand it contains two, one should be the git repo (still nameless) 77 | // and the other should be the directory containing the chat_message_file, which is 78 | // named by the first element of the path in "chat_message_file". 79 | // This allows (although clumsily) to distinguish which is which. 80 | // This complexity has historical reasons to preserve backwards compatibility 81 | // (the nameless git repo). 82 | // 83 | // Somehow independent is the reason why we enforce the count of directories to be 84 | // max 2: this is to avoid the default Concourse behavior of streaming _all_ the 85 | // volumes "just in case". 86 | 87 | params := putter.Request.Params 88 | source := putter.Request.Source 89 | 90 | // Get wanted sinks (already validated in LoadConfiguration()). 91 | sinks, _ := MergeAndValidateSinks(source.Sinks, params.Sinks) 92 | 93 | var msgDir string 94 | 95 | collected, err := collectInputDirs(putter.InputDir) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | inputDirs := sets.From(collected...) 101 | 102 | if params.ChatMessageFile != "" { 103 | msgDir, _ = path.Split(params.ChatMessageFile) 104 | msgDir = strings.TrimSuffix(msgDir, "/") 105 | if msgDir == "" { 106 | return fmt.Errorf("chat_message_file: wrong format: have: %s, want: path of the form: /", 107 | params.ChatMessageFile) 108 | } 109 | 110 | found := inputDirs.Remove(msgDir) 111 | if !found { 112 | return fmt.Errorf("put:inputs: directory for chat_message_file not found: have: %v, chat_message_file: %s", 113 | collected, params.ChatMessageFile) 114 | } 115 | } 116 | 117 | switch inputDirs.Size() { 118 | case 0: 119 | // If the size is 0 after removing the directory containing the chat message 120 | // and Cogito should update the commit status, return an error. 121 | if sinks.Contains("github") { 122 | return fmt.Errorf( 123 | "put:inputs: missing directory for GitHub repo: have: %v, GitHub: %s/%s", 124 | inputDirs, source.Owner, source.Repo) 125 | } 126 | putter.log.Debug("", "inputDirs", inputDirs, "msgDir", msgDir) 127 | case 1: 128 | repoDir := filepath.Join(putter.InputDir, inputDirs.OrderedList()[0]) 129 | putter.log.Debug("", "inputDirs", inputDirs, "repoDir", repoDir, "msgDir", msgDir) 130 | if err := checkGitRepoDir(repoDir, source.GhHostname, source.Owner, source.Repo); err != nil { 131 | return err 132 | } 133 | putter.gitRef, err = getGitCommit(repoDir) 134 | if err != nil { 135 | return err 136 | } 137 | putter.log.Debug("", "git-ref", putter.gitRef) 138 | default: 139 | // If the size exceeds 1, too many directories are passed to Cogito. 140 | return fmt.Errorf( 141 | "put:inputs: want only directory for GitHub repo: have: %v, GitHub: %s/%s", 142 | inputDirs, source.Owner, source.Repo) 143 | } 144 | 145 | return nil 146 | } 147 | 148 | func (putter *ProdPutter) Sinks() []Sinker { 149 | supportedSinkers := map[string]Sinker{ 150 | "github": GitHubCommitStatusSink{ 151 | Log: putter.log.With("name", "ghCommitStatus"), 152 | GitRef: putter.gitRef, 153 | Request: putter.Request, 154 | }, 155 | "gchat": GoogleChatSink{ 156 | Log: putter.log.With("name", "gChat"), 157 | // TODO putter.InputDir itself should be of type fs.FS. 158 | InputDir: os.DirFS(putter.InputDir), 159 | GitRef: putter.gitRef, 160 | Request: putter.Request, 161 | }, 162 | } 163 | source := putter.Request.Source.Sinks 164 | params := putter.Request.Params.Sinks 165 | sinks, _ := MergeAndValidateSinks(source, params) 166 | 167 | sinkers := make([]Sinker, 0, sinks.Size()) 168 | for _, s := range sinks.OrderedList() { 169 | sinkers = append(sinkers, supportedSinkers[s]) 170 | } 171 | 172 | return sinkers 173 | } 174 | 175 | func (putter *ProdPutter) Output(out io.Writer) error { 176 | // Following the protocol for put, we return the version and metadata. 177 | // For Cogito, the metadata contains the Concourse build state. 178 | output := Output{ 179 | Version: DummyVersion, 180 | Metadata: []Metadata{{Name: KeyState, Value: string(putter.Request.Params.State)}}, 181 | } 182 | enc := json.NewEncoder(out) 183 | if err := enc.Encode(output); err != nil { 184 | return fmt.Errorf("put: %s", err) 185 | } 186 | 187 | putter.log.Debug("success", "output.version", output.Version, 188 | "output.metadata", output.Metadata) 189 | 190 | return nil 191 | } 192 | 193 | // MergeAndValidateSinks returns an error if the user set an unsupported sink in source or put.params. 194 | // If validation passes, it return the list of sinks to address: 195 | // - return sinks in put.params if found. 196 | // - return sinks in source if found. 197 | // - return all supported sinks. 198 | func MergeAndValidateSinks(sourceSinks []string, paramsSinks []string) (*sets.Set[string], error) { 199 | sinks := sets.From([]string{"github", "gchat"}...) 200 | supportedSinks := sinks 201 | if len(sourceSinks) > 0 { 202 | sinks = sets.From(sourceSinks...) 203 | } 204 | if len(paramsSinks) > 0 { 205 | sinks = sets.From(paramsSinks...) 206 | } 207 | 208 | difference := sinks.Difference(supportedSinks) 209 | if difference.Size() > 0 { 210 | return nil, fmt.Errorf("%s", difference) 211 | } 212 | return sinks, nil 213 | } 214 | 215 | // collectInputDirs returns a list of all directories below dir (non-recursive). 216 | func collectInputDirs(dir string) ([]string, error) { 217 | entries, err := os.ReadDir(dir) 218 | if err != nil { 219 | return nil, fmt.Errorf("collecting directories in %v: %w", dir, err) 220 | } 221 | var dirs []string 222 | for _, e := range entries { 223 | if e.IsDir() { 224 | dirs = append(dirs, e.Name()) 225 | } 226 | } 227 | return dirs, nil 228 | } 229 | 230 | // checkGitRepoDir validates whether DIR, assumed to be received as input of a put step, 231 | // contains a git repository usable with the Cogito source configuration: 232 | // - DIR is indeed a git repository. 233 | // - The repo configuration contains a "remote origin" section. 234 | // - The remote origin url can be parsed following the GitHub conventions. 235 | // - The result of the parse matches OWNER and REPO. 236 | func checkGitRepoDir(dir, hostname, owner, repo string) error { 237 | cfg, err := mini.LoadConfiguration(filepath.Join(dir, ".git/config")) 238 | if err != nil { 239 | return fmt.Errorf("parsing .git/config: %w", err) 240 | } 241 | 242 | // .git/config contains a section like: 243 | // 244 | // [remote "origin"] 245 | // url = git@github.com:Pix4D/cogito.git 246 | // fetch = +refs/heads/*:refs/remotes/origin/* 247 | // 248 | const section = `remote "origin"` 249 | const key = "url" 250 | gitUrl := cfg.StringFromSection(section, key, "") 251 | if gitUrl == "" { 252 | return fmt.Errorf(".git/config: key [%s]/%s: not found", section, key) 253 | } 254 | gu, err := github.ParseGitPseudoURL(gitUrl) 255 | if err != nil { 256 | return fmt.Errorf(".git/config: remote: %w", err) 257 | } 258 | left := []string{hostname, owner, repo} 259 | right := []string{gu.URL.Host, gu.Owner, gu.Repo} 260 | for i, l := range left { 261 | r := right[i] 262 | if !strings.EqualFold(l, r) { 263 | return fmt.Errorf(`the received git repository is incompatible with the Cogito configuration. 264 | 265 | Git repository configuration (received as 'inputs:' in this PUT step): 266 | url: %s 267 | owner: %s 268 | repo: %s 269 | 270 | Cogito SOURCE configuration: 271 | hostname: %s 272 | owner: %s 273 | repo: %s`, 274 | gitUrl, gu.Owner, gu.Repo, 275 | hostname, owner, repo) 276 | } 277 | } 278 | return nil 279 | } 280 | 281 | // getGitCommit looks into a git repository and extracts the commit SHA of the HEAD. 282 | func getGitCommit(repoPath string) (string, error) { 283 | dotGitPath := filepath.Join(repoPath, ".git") 284 | 285 | headPath := filepath.Join(dotGitPath, "HEAD") 286 | headBuf, err := os.ReadFile(headPath) 287 | if err != nil { 288 | return "", fmt.Errorf("git commit: read HEAD: %w", err) 289 | } 290 | 291 | // The HEAD file can have two completely different contents: 292 | // 1. if a branch checkout: "ref: refs/heads/BRANCH_NAME" 293 | // 2. if a detached head : the commit SHA 294 | // A detached head with Concourse happens in two cases: 295 | // 1. if the git resource has a `tag_filter:` 296 | // 2. if the git resource has a `version:` 297 | 298 | head := strings.TrimSuffix(string(headBuf), "\n") 299 | tokens := strings.Fields(head) 300 | var sha string 301 | switch len(tokens) { 302 | case 1: 303 | // detached head 304 | sha = head 305 | case 2: 306 | // branch checkout 307 | shaRelPath := tokens[1] 308 | shaPath := filepath.Join(dotGitPath, shaRelPath) 309 | shaBuf, err := os.ReadFile(shaPath) 310 | if err != nil { 311 | return "", fmt.Errorf("git commit: branch checkout: read SHA file: %w", err) 312 | } 313 | sha = strings.TrimSuffix(string(shaBuf), "\n") 314 | default: 315 | return "", fmt.Errorf("git commit: invalid HEAD format: %q", head) 316 | } 317 | 318 | return sha, nil 319 | } 320 | 321 | // concourseBuildURL builds a URL pointing to a specific build of a job in a pipeline. 322 | func concourseBuildURL(env Environment) string { 323 | // Example: 324 | // https://ci.example.com/teams/main/pipelines/cogito/jobs/hello/builds/25 325 | buildURL := env.AtcExternalUrl + path.Join( 326 | "/teams", env.BuildTeamName, 327 | "pipelines", env.BuildPipelineName, 328 | "jobs", env.BuildJobName, 329 | "builds", env.BuildName) 330 | 331 | // Example: 332 | // BUILD_PIPELINE_INSTANCE_VARS: {"branch":"stable"} 333 | // https://ci.example.com/teams/main/pipelines/cogito/jobs/autocat/builds/3?vars=%7B%22branch%22%3A%22stable%22%7D 334 | if env.BuildPipelineInstanceVars != "" { 335 | buildURL += fmt.Sprintf("?vars=%s", url.QueryEscape(env.BuildPipelineInstanceVars)) 336 | // Concourse requires that the space characters 337 | // are encoded as %20 instead of +. On the other hand 338 | // golangs url.QueryEscape as well as url.Values.Encode() 339 | // both encode the space as a + character. 340 | // See: https://github.com/golang/go/issues/4013 341 | buildURL = strings.ReplaceAll(buildURL, "+", "%20") 342 | } 343 | 344 | return buildURL 345 | } 346 | -------------------------------------------------------------------------------- /cogito/putter_private_test.go: -------------------------------------------------------------------------------- 1 | package cogito 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "gotest.tools/v3/assert" 11 | 12 | "github.com/Pix4D/cogito/testhelp" 13 | ) 14 | 15 | func TestCollectInputDirs(t *testing.T) { 16 | type testCase = struct { 17 | name string 18 | dir string 19 | wantErr error 20 | wantN int 21 | } 22 | 23 | test := func(t *testing.T, tc testCase) { 24 | dirs, err := collectInputDirs(tc.dir) 25 | if !errors.Is(err, tc.wantErr) { 26 | t.Errorf("sut(%v): error: have %v; want %v", tc.dir, err, tc.wantErr) 27 | } 28 | gotN := len(dirs) 29 | if gotN != tc.wantN { 30 | t.Errorf("sut(%v): len(dirs): have %v; want %v", tc.dir, gotN, tc.wantN) 31 | } 32 | } 33 | 34 | var testCases = []testCase{ 35 | { 36 | name: "non existing base directory", 37 | dir: "non-existing", 38 | wantErr: os.ErrNotExist, 39 | wantN: 0, 40 | }, 41 | { 42 | name: "empty directory", 43 | dir: "testdata/empty-dir", 44 | wantErr: nil, 45 | wantN: 0, 46 | }, 47 | { 48 | name: "two directories and one file", 49 | dir: "testdata/two-dirs", 50 | wantErr: nil, 51 | wantN: 2, 52 | }, 53 | } 54 | 55 | for _, tc := range testCases { 56 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 57 | } 58 | } 59 | 60 | func TestCheckGitRepoDirSuccess(t *testing.T) { 61 | type testCase struct { 62 | name string 63 | dir string // repoURL to put in file /.git/config 64 | repoURL string 65 | } 66 | 67 | const wantHostname = "github.com" 68 | const wantOwner = "smiling" 69 | const wantRepo = "butterfly" 70 | 71 | test := func(t *testing.T, tc testCase) { 72 | inputDir := testhelp.MakeGitRepoFromTestdata(t, tc.dir, tc.repoURL, 73 | "dummySHA", "dummyHead") 74 | 75 | err := checkGitRepoDir(filepath.Join(inputDir, filepath.Base(tc.dir)), 76 | wantHostname, wantOwner, wantRepo) 77 | 78 | assert.NilError(t, err) 79 | } 80 | 81 | testCases := []testCase{ 82 | { 83 | name: "repo with good SSH remote", 84 | dir: "testdata/one-repo/a-repo", 85 | repoURL: testhelp.SshRemote(wantHostname, wantOwner, wantRepo), 86 | }, 87 | { 88 | name: "repo with good HTTPS remote", 89 | dir: "testdata/one-repo/a-repo", 90 | repoURL: testhelp.HttpsRemote(wantHostname, wantOwner, wantRepo), 91 | }, 92 | { 93 | name: "repo with good HTTP remote", 94 | dir: "testdata/one-repo/a-repo", 95 | repoURL: testhelp.HttpRemote(wantHostname, wantOwner, wantRepo), 96 | }, 97 | { 98 | name: "PR resource but with basic auth in URL (see PR #46)", 99 | dir: "testdata/one-repo/a-repo", 100 | repoURL: fmt.Sprintf("https://x-oauth-basic:ghp_XXX@%s/%s/%s.git", wantHostname, wantOwner, wantRepo), 101 | }, 102 | } 103 | 104 | for _, tc := range testCases { 105 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 106 | } 107 | } 108 | 109 | func TestCheckGitRepoDirFailure(t *testing.T) { 110 | type testCase struct { 111 | name string 112 | dir string 113 | repoURL string // repoURL to put in file /.git/config 114 | wantErrWild string // wildcard matching 115 | } 116 | 117 | const wantHostname = "github.com" 118 | const wantOwner = "smiling" 119 | const wantRepo = "butterfly" 120 | 121 | test := func(t *testing.T, tc testCase) { 122 | inDir := testhelp.MakeGitRepoFromTestdata(t, tc.dir, tc.repoURL, 123 | "dummySHA", "dummyHead") 124 | 125 | err := checkGitRepoDir(filepath.Join(inDir, filepath.Base(tc.dir)), 126 | wantHostname, wantOwner, wantRepo) 127 | 128 | assert.ErrorContains(t, err, tc.wantErrWild) 129 | } 130 | 131 | testCases := []testCase{ 132 | { 133 | name: "dir is not a repo", 134 | dir: "testdata/not-a-repo", 135 | repoURL: "dummyurl", 136 | wantErrWild: "parsing .git/config: open ", 137 | }, 138 | { 139 | name: "bad file .git/config", 140 | dir: "testdata/repo-bad-git-config", 141 | repoURL: "dummyurl", 142 | wantErrWild: `.git/config: key [remote "origin"]/url: not found`, 143 | }, 144 | { 145 | name: "repo with unrelated HTTPS remote", 146 | dir: "testdata/one-repo/a-repo", 147 | repoURL: testhelp.HttpsRemote("github.com", "owner-a", "repo-a"), 148 | wantErrWild: `the received git repository is incompatible with the Cogito configuration. 149 | 150 | Git repository configuration (received as 'inputs:' in this PUT step): 151 | url: https://github.com/owner-a/repo-a.git 152 | owner: owner-a 153 | repo: repo-a 154 | 155 | Cogito SOURCE configuration: 156 | hostname: github.com 157 | owner: smiling 158 | repo: butterfly`, 159 | }, 160 | { 161 | name: "repo with unrelated SSH remote or wrong source config", 162 | dir: "testdata/one-repo/a-repo", 163 | repoURL: testhelp.SshRemote("github.com", "owner-a", "repo-a"), 164 | wantErrWild: `the received git repository is incompatible with the Cogito configuration. 165 | 166 | Git repository configuration (received as 'inputs:' in this PUT step): 167 | url: git@github.com:owner-a/repo-a.git 168 | owner: owner-a 169 | repo: repo-a 170 | 171 | Cogito SOURCE configuration: 172 | hostname: github.com 173 | owner: smiling 174 | repo: butterfly`, 175 | }, 176 | { 177 | name: "invalid git pseudo URL in .git/config", 178 | dir: "testdata/one-repo/a-repo", 179 | repoURL: "foo://bar", 180 | wantErrWild: `.git/config: remote: invalid git URL foo://bar: invalid scheme: foo`, 181 | }, 182 | } 183 | 184 | for _, tc := range testCases { 185 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 186 | } 187 | } 188 | 189 | func TestGitGetCommitSuccess(t *testing.T) { 190 | type testCase struct { 191 | name string 192 | dir string 193 | repoURL string 194 | head string 195 | } 196 | 197 | const wantSHA = "af6cd86e98eb1485f04d38b78d9532e916bbff02" 198 | const defHead = "ref: refs/heads/a-branch-FIXME" 199 | 200 | test := func(t *testing.T, tc testCase) { 201 | tmpDir := testhelp.MakeGitRepoFromTestdata(t, tc.dir, tc.repoURL, wantSHA, tc.head) 202 | 203 | sha, err := getGitCommit(filepath.Join(tmpDir, filepath.Base(tc.dir))) 204 | 205 | assert.NilError(t, err) 206 | assert.Equal(t, sha, wantSHA) 207 | } 208 | 209 | testCases := []testCase{ 210 | { 211 | name: "happy path for branch checkout", 212 | dir: "testdata/one-repo/a-repo", 213 | repoURL: "dummy", 214 | head: defHead, 215 | }, 216 | { 217 | name: "happy path for detached HEAD checkout", 218 | dir: "testdata/one-repo/a-repo", 219 | repoURL: "dummy", 220 | head: wantSHA, 221 | }, 222 | } 223 | 224 | for _, tc := range testCases { 225 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 226 | } 227 | } 228 | 229 | func TestGitGetCommitFailure(t *testing.T) { 230 | type testCase struct { 231 | name string 232 | dir string 233 | repoURL string 234 | head string 235 | wantErr string 236 | } 237 | 238 | const wantSHA = "af6cd86e98eb1485f04d38b78d9532e916bbff02" 239 | 240 | test := func(t *testing.T, tc testCase) { 241 | tmpDir := testhelp.MakeGitRepoFromTestdata(t, tc.dir, tc.repoURL, wantSHA, tc.head) 242 | 243 | _, err := getGitCommit(filepath.Join(tmpDir, filepath.Base(tc.dir))) 244 | 245 | assert.ErrorContains(t, err, tc.wantErr) 246 | } 247 | 248 | testCases := []testCase{ 249 | { 250 | name: "missing HEAD", 251 | dir: "testdata/not-a-repo", 252 | repoURL: "dummy", 253 | head: "dummy", 254 | wantErr: "git commit: read HEAD: open ", 255 | }, 256 | { 257 | name: "invalid format for HEAD", 258 | dir: "testdata/one-repo/a-repo", 259 | repoURL: "dummyURL", 260 | head: "this is a bad head", 261 | wantErr: `git commit: invalid HEAD format: "this is a bad head"`, 262 | }, 263 | { 264 | name: "HEAD points to non-existent file", 265 | dir: "testdata/one-repo/a-repo", 266 | repoURL: "dummyURL", 267 | head: "banana mango", 268 | wantErr: "git commit: branch checkout: read SHA file: open ", 269 | }, 270 | } 271 | 272 | for _, tc := range testCases { 273 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 274 | } 275 | } 276 | 277 | func TestMultiErrString(t *testing.T) { 278 | type testCase struct { 279 | name string 280 | errs []error 281 | wantErr string 282 | } 283 | 284 | test := func(t *testing.T, tc testCase) { 285 | assert.Equal(t, multiErrString(tc.errs), tc.wantErr) 286 | } 287 | 288 | testCases := []testCase{ 289 | { 290 | name: "one error", 291 | errs: []error{errors.New("error 1")}, 292 | wantErr: "error 1", 293 | }, 294 | { 295 | name: "multiple errors", 296 | errs: []error{errors.New("error 1"), errors.New("error 2")}, 297 | wantErr: `multiple errors: 298 | error 1 299 | error 2`, 300 | }, 301 | } 302 | 303 | for _, tc := range testCases { 304 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 305 | } 306 | } 307 | 308 | func TestConcourseBuildURL(t *testing.T) { 309 | type testCase struct { 310 | name string 311 | env Environment 312 | want string 313 | } 314 | 315 | test := func(t *testing.T, tc testCase) { 316 | if tc.want == "" { 317 | t.Fatal("tc.want: empty") 318 | } 319 | 320 | have := concourseBuildURL(tc.env) 321 | 322 | if have != tc.want { 323 | t.Fatalf("\nhave: %s\nwant: %s", have, tc.want) 324 | } 325 | } 326 | 327 | baseEnv := Environment{ 328 | BuildId: "", 329 | BuildName: "42", 330 | BuildJobName: "paint", 331 | BuildPipelineName: "magritte", 332 | BuildPipelineInstanceVars: "", 333 | BuildTeamName: "devs", 334 | BuildCreatedBy: "", 335 | AtcExternalUrl: "https://ci.example.com", 336 | } 337 | 338 | testCases := []testCase{ 339 | { 340 | name: "all defaults", 341 | env: baseEnv, 342 | want: "https://ci.example.com/teams/devs/pipelines/magritte/jobs/paint/builds/42", 343 | }, 344 | { 345 | name: "single instance variable", 346 | env: testhelp.MergeStructs(baseEnv, 347 | Environment{BuildPipelineInstanceVars: `{"branch":"stable"}`}), 348 | want: "https://ci.example.com/teams/devs/pipelines/magritte/jobs/paint/builds/42?vars=%7B%22branch%22%3A%22stable%22%7D", 349 | }, 350 | { 351 | name: "single instance variable with spaces", 352 | env: testhelp.MergeStructs(baseEnv, 353 | Environment{BuildPipelineInstanceVars: `{"branch":"foo bar"}`}), 354 | want: "https://ci.example.com/teams/devs/pipelines/magritte/jobs/paint/builds/42?vars=%7B%22branch%22%3A%22foo%20bar%22%7D", 355 | }, 356 | { 357 | name: "multiple instance variables", 358 | env: testhelp.MergeStructs(baseEnv, 359 | Environment{BuildPipelineInstanceVars: `{"branch":"stable","foo":"bar"}`}), 360 | want: "https://ci.example.com/teams/devs/pipelines/magritte/jobs/paint/builds/42?vars=%7B%22branch%22%3A%22stable%22%2C%22foo%22%3A%22bar%22%7D", 361 | }, 362 | { 363 | name: "multiple instance variables: nested json with spaces", 364 | env: testhelp.MergeStructs(baseEnv, 365 | Environment{BuildPipelineInstanceVars: `{"branch":"foo bar","version":{"from":1.0,"to":2.0}}`}), 366 | want: "https://ci.example.com/teams/devs/pipelines/magritte/jobs/paint/builds/42?vars=%7B%22branch%22%3A%22foo%20bar%22%2C%22version%22%3A%7B%22from%22%3A1.0%2C%22to%22%3A2.0%7D%7D", 367 | }, 368 | } 369 | 370 | for _, tc := range testCases { 371 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /cogito/testdata/empty-dir/.git_keepme: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/empty-dir/.git_keepme -------------------------------------------------------------------------------- /cogito/testdata/not-a-repo/a-dir/.git_keepme: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/not-a-repo/a-dir/.git_keepme -------------------------------------------------------------------------------- /cogito/testdata/not-a-repo/a-file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/not-a-repo/a-file -------------------------------------------------------------------------------- /cogito/testdata/one-repo/a-repo/dot.git/HEAD.template: -------------------------------------------------------------------------------- 1 | {{.head}} 2 | -------------------------------------------------------------------------------- /cogito/testdata/one-repo/a-repo/dot.git/config.template: -------------------------------------------------------------------------------- 1 | # This is not a real git repo; it is testdata using Go templating. 2 | [remote "origin"] 3 | url = {{.repo_url}} 4 | -------------------------------------------------------------------------------- /cogito/testdata/one-repo/a-repo/dot.git/refs/heads/{{.branch_name}}.template: -------------------------------------------------------------------------------- 1 | {{.commit_sha}} 2 | -------------------------------------------------------------------------------- /cogito/testdata/only-msgdir/msgdir/msg.txt: -------------------------------------------------------------------------------- 1 | ten bananas please 2 | -------------------------------------------------------------------------------- /cogito/testdata/repo-and-msgdir/a-repo/dot.git/HEAD.template: -------------------------------------------------------------------------------- 1 | {{.head}} 2 | -------------------------------------------------------------------------------- /cogito/testdata/repo-and-msgdir/a-repo/dot.git/config.template: -------------------------------------------------------------------------------- 1 | # This is not a real git repo; it is testdata using Go templating. 2 | [remote "origin"] 3 | url = {{.repo_url}} 4 | -------------------------------------------------------------------------------- /cogito/testdata/repo-and-msgdir/a-repo/dot.git/refs/heads/{{.branch_name}}.template: -------------------------------------------------------------------------------- 1 | {{.commit_sha}} 2 | -------------------------------------------------------------------------------- /cogito/testdata/repo-and-msgdir/msgdir/msg.txt: -------------------------------------------------------------------------------- 1 | ten bananas please 2 | -------------------------------------------------------------------------------- /cogito/testdata/repo-bad-git-config/dot.git/config: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/repo-bad-git-config/dot.git/config -------------------------------------------------------------------------------- /cogito/testdata/two-dirs/.git_keepme: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/two-dirs/.git_keepme -------------------------------------------------------------------------------- /cogito/testdata/two-dirs/dir-1/.git_keepme: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/two-dirs/dir-1/.git_keepme -------------------------------------------------------------------------------- /cogito/testdata/two-dirs/dir-2/.git_keepme: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/two-dirs/dir-2/.git_keepme -------------------------------------------------------------------------------- /cogito/testdata/two-dirs/dir-2/hello: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/cogito/testdata/two-dirs/dir-2/hello -------------------------------------------------------------------------------- /doc/cogito-gchat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/doc/cogito-gchat.png -------------------------------------------------------------------------------- /doc/gh-ui-decorated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/doc/gh-ui-decorated.png -------------------------------------------------------------------------------- /doc/version-is-missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pix4D/cogito/ec7ec2e58008e43d81555662f0efab383f5bbf44/doc/version-is-missing.png -------------------------------------------------------------------------------- /github/commitstatus.go: -------------------------------------------------------------------------------- 1 | // Package github implements the GitHub APIs used by Cogito (Commit status API). 2 | // 3 | // See the README and CONTRIBUTING files for additional information, caveats about GitHub 4 | // API and imposed limits, and reference to official documentation. 5 | package github 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "log/slog" 15 | "net/http" 16 | "path" 17 | "regexp" 18 | "strings" 19 | "time" 20 | 21 | "github.com/Pix4D/cogito/retry" 22 | ) 23 | 24 | // StatusError is one of the possible errors returned by the github package. 25 | type StatusError struct { 26 | What string 27 | StatusCode int 28 | Details string 29 | } 30 | 31 | func (e *StatusError) Error() string { 32 | return fmt.Sprintf("%s\n%s", e.What, e.Details) 33 | } 34 | 35 | // GhDefaultHostname is the default GitHub hostname (used for git but not for the API) 36 | const GhDefaultHostname = "github.com" 37 | 38 | var localhostRegexp = regexp.MustCompile(`^127.0.0.1:[0-9]+$`) 39 | 40 | type Target struct { 41 | // Client is the http client 42 | Client *http.Client 43 | // Server is the GitHub API server. 44 | Server string 45 | // Retry controls the retry logic. 46 | Retry retry.Retry 47 | } 48 | 49 | // CommitStatus is a wrapper to the GitHub API to set the commit status for a specific 50 | // GitHub owner and repo. 51 | // See also: 52 | // - NewCommitStatus 53 | // - https://docs.github.com/en/rest/commits/statuses 54 | type CommitStatus struct { 55 | target *Target 56 | token string 57 | owner string 58 | repo string 59 | context string 60 | 61 | log *slog.Logger 62 | } 63 | 64 | // NewCommitStatus returns a CommitStatus object associated to a specific GitHub owner 65 | // and repo. 66 | // Parameter token is the personal OAuth token of a user that has write access to the 67 | // repo. It only needs the repo:status scope. 68 | // Parameter context is what created the status, for example "JOBNAME", or 69 | // "PIPELINENAME/JOBNAME". The name comes from the GitHub API. 70 | // Be careful when using PIPELINENAME: if that name is ephemeral, it will make it 71 | // impossible to use GitHub repository branch protection rules. 72 | // 73 | // See also: 74 | // - https://docs.github.com/en/rest/commits/statuses 75 | func NewCommitStatus( 76 | target *Target, 77 | token, owner, repo, context string, 78 | log *slog.Logger, 79 | ) CommitStatus { 80 | return CommitStatus{ 81 | target: target, 82 | token: token, 83 | owner: owner, 84 | repo: repo, 85 | context: context, 86 | log: log, 87 | } 88 | } 89 | 90 | // AddRequest is the JSON object sent to the API. 91 | type AddRequest struct { 92 | State string `json:"state"` 93 | TargetURL string `json:"target_url"` 94 | Description string `json:"description"` 95 | Context string `json:"context"` 96 | } 97 | 98 | // Add sets the commit state to the given sha, decorating it with targetURL and optional 99 | // description. 100 | // In case of transient errors or rate limiting by the backend, Add performs a certain 101 | // number of attempts before giving up. The retry logic is configured in the Target.Retry 102 | // parameter of NewCommitStatus. 103 | // Parameter sha is the 40 hexadecimal digit sha associated to the commit to decorate. 104 | // Parameter state is one of error, failure, pending, success. 105 | // Parameter targetURL (optional) points to the specific process (for example, a CI build) 106 | // that generated this state. 107 | // Parameter description (optional) gives more information about the status. 108 | // The returned error contains some diagnostic information to help troubleshooting. 109 | // 110 | // See also: https://docs.github.com/en/rest/commits/statuses#create-a-commit-status 111 | func (cs CommitStatus) Add(ctx context.Context, sha, state, targetURL, description string) error { 112 | // API: POST /repos/{owner}/{repo}/statuses/{sha} 113 | url := cs.target.Server + path.Join("/repos", cs.owner, cs.repo, "statuses", sha) 114 | 115 | reqBody := AddRequest{ 116 | State: state, 117 | TargetURL: targetURL, 118 | Description: description, 119 | Context: cs.context, 120 | } 121 | 122 | reqBodyJSON, err := json.Marshal(reqBody) 123 | if err != nil { 124 | return fmt.Errorf("JSON encode: %w", err) 125 | } 126 | 127 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(reqBodyJSON)) 128 | if err != nil { 129 | return fmt.Errorf("create http request: %w", err) 130 | } 131 | req.Header.Set("Authorization", "token "+cs.token) 132 | req.Header.Set("Accept", "application/vnd.github.v3+json") 133 | req.Header.Set("Content-Type", "application/json") 134 | 135 | // The retryable unit of work. 136 | workFn := func() error { 137 | start := time.Now() 138 | resp, err := cs.target.Client.Do(req) 139 | if err != nil { 140 | return fmt.Errorf("http client Do: %w", err) 141 | } 142 | defer resp.Body.Close() 143 | 144 | elapsed := time.Since(start) 145 | remaining := resp.Header.Get("X-RateLimit-Remaining") 146 | limit := resp.Header.Get("X-RateLimit-Limit") 147 | reset := resp.Header.Get("X-RateLimit-Reset") 148 | cs.log.Debug( 149 | "http-request", 150 | "method", req.Method, 151 | "url", req.URL, 152 | "status", resp.StatusCode, 153 | "duration", elapsed, 154 | "rate-limit", limit, 155 | "rate-limit-remaining", remaining, 156 | "rate-limit-reset", reset, 157 | ) 158 | 159 | if resp.StatusCode == http.StatusCreated { 160 | return nil 161 | } 162 | 163 | body, _ := io.ReadAll(resp.Body) 164 | return NewGitHubError(resp, errors.New(strings.TrimSpace(string(body)))) 165 | } 166 | 167 | if err := cs.target.Retry.Do(Backoff, Classifier, workFn); err != nil { 168 | return cs.explainError(err, state, sha, url) 169 | } 170 | 171 | return nil 172 | } 173 | 174 | // TODO: can we merge (at least partially) this function in GitHubError.Error ? 175 | // As-is, it is redundant. On the other hand, GitHubError.Error is now public 176 | // and used by other tools, so we must not merge hints specific to the 177 | // Commit Status API. 178 | func (cs CommitStatus) explainError(err error, state, sha, url string) error { 179 | commonWhat := fmt.Sprintf( 180 | "failed to add state %q for commit %s", 181 | state, 182 | sha[0:min(len(sha), 7)], 183 | ) 184 | var ghErr GitHubError 185 | if errors.As(err, &ghErr) { 186 | hint := "none" 187 | switch ghErr.StatusCode { 188 | case http.StatusNotFound: 189 | hint = fmt.Sprintf(`one of the following happened: 190 | 1. The repo https://github.com/%s doesn't exist 191 | 2. The user who issued the token doesn't have write access to the repo 192 | 3. The token doesn't have scope repo:status`, 193 | path.Join(cs.owner, cs.repo)) 194 | case http.StatusInternalServerError: 195 | hint = "Github API is down" 196 | case http.StatusUnauthorized: 197 | hint = "Either wrong credentials or PAT expired (check your email for expiration notice)" 198 | case http.StatusForbidden: 199 | if ghErr.RateLimitRemaining == 0 { 200 | hint = fmt.Sprintf( 201 | "Rate limited but the wait time to reset would be longer than %v (Retry.UpTo)", 202 | cs.target.Retry.UpTo, 203 | ) 204 | } 205 | } 206 | return &StatusError{ 207 | What: fmt.Sprintf("%s: %d %s", commonWhat, ghErr.StatusCode, 208 | http.StatusText(ghErr.StatusCode)), 209 | StatusCode: ghErr.StatusCode, 210 | Details: fmt.Sprintf("Body: %s\nHint: %s\nAction: %s %s\nOAuth: %s", 211 | ghErr, hint, http.MethodPost, url, ghErr.OauthInfo), 212 | } 213 | } 214 | 215 | return &StatusError{ 216 | What: fmt.Sprintf("%s: %s", commonWhat, err), 217 | Details: fmt.Sprintf("Action: %s %s", http.MethodPost, url), 218 | } 219 | } 220 | 221 | // ApiRoot constructs the root part of the GitHub API URL for a given hostname. 222 | // Example: 223 | // if hostname is github.com it returns https://api.github.com 224 | // if hostname looks like a httptest server, it returns http://127.0.0.1:PORT 225 | // otherwise, hostname is assumed to be of a Github Enterprise instance. 226 | // For example, github.mycompany.org returns https://github.mycompany.org/api/v3 227 | func ApiRoot(h string) string { 228 | hostname := strings.ToLower(h) 229 | if hostname == GhDefaultHostname { 230 | return "https://api.github.com" 231 | } 232 | if localhostRegexp.MatchString(hostname) { 233 | return fmt.Sprintf("http://%s", hostname) 234 | } 235 | return fmt.Sprintf("https://%s/api/v3", hostname) 236 | } 237 | -------------------------------------------------------------------------------- /github/githubapp.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "path" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/golang-jwt/jwt/v5" 14 | ) 15 | 16 | type GitHubApp struct { 17 | ClientId string `json:"client_id"` 18 | InstallationId int `json:"installation_id"` 19 | PrivateKey string `json:"private_key"` // SENSITIVE 20 | } 21 | 22 | func (app *GitHubApp) IsZero() bool { 23 | return *app == GitHubApp{} 24 | } 25 | 26 | // generateJWTtoken returns a signed JWT token used to authenticate as GitHub App 27 | func generateJWTtoken(clientId, privateKey string) (string, error) { 28 | key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey)) 29 | if err != nil { 30 | return "", fmt.Errorf("could not parse private key: %w", err) 31 | } 32 | // GitHub rejects expiresAt (exp) and issuedAt (iat) timestamps that are not an integer, 33 | // while the jwt-go library serializes to fractional timestamps. 34 | // Truncate them before passing to jwt-go. 35 | // Additionally, GitHub recommends setting this value to 60 seconds in the past. 36 | issuedAt := time.Now().Add(-60 * time.Second).Truncate(time.Second) 37 | // Github set the maximum validity of a token to 10 minutes. Here, we reduce it to 1 minute 38 | // (we set expiresAt to 2 minutes, but we start 1 minute in the past). 39 | expiresAt := issuedAt.Add(2 * time.Minute) 40 | // Docs: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#about-json-web-tokens-jwts 41 | claims := &jwt.RegisteredClaims{ 42 | IssuedAt: jwt.NewNumericDate(issuedAt), 43 | ExpiresAt: jwt.NewNumericDate(expiresAt), 44 | // The client ID or application ID of your GitHub App. Use of the client ID is recommended. 45 | Issuer: clientId, 46 | } 47 | 48 | // GitHub JWT must be signed using the RS256 algorithm. 49 | token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key) 50 | if err != nil { 51 | return "", fmt.Errorf("could not sign the JWT token: %w", err) 52 | } 53 | return token, nil 54 | } 55 | 56 | // GenerateInstallationToken returns an installation token used to authenticate as GitHub App installation 57 | func GenerateInstallationToken(ctx context.Context, client *http.Client, server string, app GitHubApp) (string, error) { 58 | // API: POST /app/installations/{installationId}/access_tokens 59 | installationId := strconv.Itoa(app.InstallationId) 60 | url := server + path.Join("/app/installations", installationId, "/access_tokens") 61 | 62 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) 63 | if err != nil { 64 | return "", fmt.Errorf("github post: new request: %s", err) 65 | } 66 | req.Header.Add("Accept", "application/vnd.github.v3+json") 67 | 68 | jwtToken, err := generateJWTtoken(app.ClientId, app.PrivateKey) 69 | if err != nil { 70 | return "", err 71 | } 72 | req.Header.Set("Authorization", "Bearer "+jwtToken) 73 | 74 | // FIXME: add retry here... 75 | resp, err := client.Do(req) 76 | if err != nil { 77 | return "", fmt.Errorf("http client Do: %s", err) 78 | } 79 | defer resp.Body.Close() 80 | 81 | body, errBody := io.ReadAll(resp.Body) 82 | if resp.StatusCode != http.StatusCreated { 83 | if errBody != nil { 84 | return "", fmt.Errorf("generate github app installation token: status code: %d (%s)", resp.StatusCode, errBody) 85 | } 86 | return "", fmt.Errorf("generate github app installation token: status code: %d (%s)", resp.StatusCode, string(body)) 87 | } 88 | if errBody != nil { 89 | return string(body), fmt.Errorf("generate github app installation token: read body: %s", errBody) 90 | } 91 | 92 | var token struct { 93 | Value string `json:"token"` 94 | } 95 | if err := json.Unmarshal(body, &token); err != nil { 96 | return "", fmt.Errorf("error: json unmarshal: %s", err) 97 | } 98 | return token.Value, nil 99 | } 100 | -------------------------------------------------------------------------------- /github/githubapp_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/Pix4D/cogito/github" 12 | "github.com/Pix4D/cogito/testhelp" 13 | "gotest.tools/v3/assert" 14 | ) 15 | 16 | func TestGenerateInstallationToken(t *testing.T) { 17 | clientID := "abcd1234" 18 | installationID := 12345 19 | 20 | privateKey, err := testhelp.GeneratePrivateKey(t, 2048) 21 | assert.NilError(t, err) 22 | 23 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 24 | defer cancel() 25 | 26 | handler := func(w http.ResponseWriter, r *http.Request) { 27 | if r.Method != http.MethodPost { 28 | w.WriteHeader(http.StatusMethodNotAllowed) 29 | fmt.Fprintln(w, "wrong HTTP method") 30 | return 31 | } 32 | 33 | claims := testhelp.DecodeJWT(t, r, privateKey) 34 | if claims.Issuer != clientID { 35 | w.WriteHeader(http.StatusUnauthorized) 36 | fmt.Fprintln(w, "unauthorized: wrong JWT token") 37 | return 38 | } 39 | w.WriteHeader(http.StatusCreated) 40 | fmt.Fprintln(w, `{"token": "dummy_installation_token"}`) 41 | } 42 | 43 | ts := httptest.NewServer(http.HandlerFunc(handler)) 44 | defer ts.Close() 45 | 46 | gotToken, err := github.GenerateInstallationToken( 47 | ctx, 48 | ts.Client(), 49 | ts.URL, 50 | github.GitHubApp{ 51 | ClientId: clientID, 52 | InstallationId: installationID, 53 | PrivateKey: string(testhelp.EncodePrivateKeyToPEM(privateKey)), 54 | }, 55 | ) 56 | 57 | assert.NilError(t, err) 58 | assert.Equal(t, "dummy_installation_token", gotToken) 59 | } 60 | 61 | func TestGitHubAppIsZero(t *testing.T) { 62 | type testCase struct { 63 | name string 64 | app github.GitHubApp 65 | want bool 66 | } 67 | 68 | run := func(t *testing.T, tc testCase) { 69 | got := tc.app.IsZero() 70 | assert.Equal(t, got, tc.want) 71 | } 72 | 73 | testCases := []testCase{ 74 | { 75 | name: "empty app", 76 | app: github.GitHubApp{}, 77 | want: true, 78 | }, 79 | { 80 | name: "one field set: client-id", 81 | app: github.GitHubApp{ClientId: "client-id"}, 82 | want: false, 83 | }, 84 | { 85 | name: "all fields set", 86 | app: github.GitHubApp{ 87 | ClientId: "client-id", 88 | InstallationId: 12345, 89 | PrivateKey: "dummy-private-key", 90 | }, 91 | want: false, 92 | }, 93 | } 94 | 95 | for _, tc := range testCases { 96 | t.Run(tc.name, func(t *testing.T) { run(t, tc) }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /github/githuberror.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type GitHubError struct { 11 | StatusCode int 12 | OauthInfo string 13 | Date time.Time 14 | RateLimitRemaining int 15 | RateLimitReset time.Time 16 | innerErr error 17 | } 18 | 19 | func NewGitHubError(httpResp *http.Response, innerErr error) error { 20 | ghErr := GitHubError{ 21 | innerErr: innerErr, 22 | StatusCode: httpResp.StatusCode, 23 | } 24 | 25 | // GH API BUG 26 | // According to 27 | // https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ 28 | // each reply to a GH API action will return these entries in the header: 29 | // 30 | // X-Accepted-OAuth-Scopes: Lists the scopes that the action checks for. 31 | // X-OAuth-Scopes: Lists the scopes your token has authorized. 32 | // 33 | // But the API action we are using here: POST /repos/:owner/:repo/statuses/:sha 34 | // 35 | // returns an empty list for X-Accepted-Oauth-Scopes, while the API documentation 36 | // https://developer.github.com/v3/repos/statuses/ says: 37 | // 38 | // Note that the repo:status OAuth scope grants targeted access to statuses 39 | // without also granting access to repository code, while the repo scope grants 40 | // permission to code as well as statuses. 41 | // 42 | // So X-Accepted-Oauth-Scopes cannot be empty, because it is a privileged operation, 43 | // and should be at least repo:status. 44 | // 45 | // Since we cannot use this information to detect configuration errors, for the time 46 | // being we report it in the error message. 47 | ghErr.OauthInfo = fmt.Sprintf("X-Accepted-Oauth-Scopes: %v, X-Oauth-Scopes: %v", 48 | httpResp.Header.Get("X-Accepted-Oauth-Scopes"), httpResp.Header.Get("X-Oauth-Scopes")) 49 | 50 | // strconv.Atoi returns 0 in case of error, Get returns "" if empty. 51 | ghErr.RateLimitRemaining, _ = strconv.Atoi(httpResp.Header.Get("X-RateLimit-Remaining")) 52 | 53 | // strconv.Atoi returns 0 in case of error, Get returns "" if empty. 54 | limit, _ := strconv.Atoi(httpResp.Header.Get("X-RateLimit-Reset")) 55 | // WARNING 56 | // If the parsing fails for any reason, limit will be set to 0. In Unix 57 | // time, 0 is the epoch, 1970-01-01, so ghErr.RateLimitReset will be set to 58 | // that date. This will cause the [Backoff] function to calculate a negative 59 | // delay. This is properly taken care of by [Backoff]. 60 | ghErr.RateLimitReset = time.Unix(int64(limit), 0) 61 | 62 | // The HTTP Date header is formatted according to RFC1123. 63 | // (https://datatracker.ietf.org/doc/html/rfc2616#section-14.18) 64 | // Example: 65 | // Date: Mon, 02 Jan 2006 15:04:05 MST 66 | date, err := time.Parse(time.RFC1123, httpResp.Header.Get("Date")) 67 | // FIXME this is not robust. Maybe log instead and put a best effort date instead? 68 | if err != nil { 69 | return fmt.Errorf("failed to parse the date header: %s", err) 70 | } 71 | ghErr.Date = date 72 | 73 | return ghErr 74 | } 75 | 76 | func (ghe GitHubError) Error() string { 77 | return ghe.innerErr.Error() 78 | } 79 | 80 | func (ghe GitHubError) Unwrap() error { 81 | return ghe.innerErr 82 | } 83 | -------------------------------------------------------------------------------- /github/retry.go: -------------------------------------------------------------------------------- 1 | // Adapters and helpers to use the [cogito/retry] package for the GitHub API. 2 | 3 | package github 4 | 5 | import ( 6 | "errors" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/Pix4D/cogito/retry" 11 | ) 12 | 13 | // Classifier implements [retry.ClassifierFunc] for GitHub. 14 | func Classifier(err error) retry.Action { 15 | if err == nil { 16 | return retry.Success 17 | } 18 | 19 | var ghErr GitHubError 20 | if errors.As(err, &ghErr) { 21 | if TransientError(ghErr.StatusCode) { 22 | return retry.SoftFail 23 | } 24 | if RateLimited(ghErr) { 25 | return retry.SoftFail 26 | } 27 | return retry.HardFail 28 | } 29 | 30 | return retry.HardFail 31 | } 32 | 33 | // Backoff implements [retry.BackoffFunc] for GitHub. 34 | func Backoff(first bool, previous, limit time.Duration, err error) time.Duration { 35 | // Optimization: Are we rate limited? 36 | // This allows to immediately terminate the retry loop if it would take too 37 | // long, instead of keeping retrying and discovering at the end that we are 38 | // still rate limited. 39 | var ghErr GitHubError 40 | if errors.As(err, &ghErr) { 41 | if RateLimited(ghErr) { 42 | // Calculate the delay based solely on the server clock. This is 43 | // unaffected by the inevitable clock drift between server and client. 44 | delay := ghErr.RateLimitReset.Sub(ghErr.Date) 45 | 46 | // We observed in production both a zero and a negative delay from 47 | // GitHub. This can be due to race conditions in the GitHub backend 48 | // or to other causes. Since this is a retry mechanism, we want to 49 | // be resilient, so we optimize only if we are 100% sure. 50 | if delay > 0 { 51 | return delay 52 | } 53 | } 54 | } 55 | 56 | // We are here for two different reasons: 57 | // 1. we are not rate limited (normal case) 58 | // 2. we are rate limited but the calculated delay is either zero or negative 59 | // (see beginning of this function). 60 | return retry.ExponentialBackoff(first, previous, limit, nil) 61 | } 62 | 63 | // RateLimited returns true if the http.Response in err reports being rate limited. 64 | // See https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#exceeding-the-rate-limit 65 | func RateLimited(err GitHubError) bool { 66 | return err.StatusCode == http.StatusForbidden && err.RateLimitRemaining == 0 67 | } 68 | 69 | // TransientError returns true if the http.Response in err has a status code 70 | // that can be retried. 71 | func TransientError(statusCode int) bool { 72 | switch statusCode { 73 | case 74 | http.StatusInternalServerError, // 500 75 | http.StatusBadGateway, // 502 76 | http.StatusServiceUnavailable, // 503 77 | http.StatusGatewayTimeout: // 504 78 | return true 79 | default: 80 | return false 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /github/url.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | type GitURL struct { 11 | URL *url.URL 12 | Owner string 13 | Repo string 14 | FullName string 15 | } 16 | 17 | // safeUrlParse wraps [url.Parse] and returns only the error and not the URL to avoid leaking 18 | // passwords of the form http://user:password@example.com 19 | // 20 | // From https://github.com/golang/go/issues/53993 21 | func safeUrlParse(rawURL string) (*url.URL, error) { 22 | parsedUrl, err := url.Parse(rawURL) 23 | if err != nil { 24 | var uerr *url.Error 25 | if errors.As(err, &uerr) { 26 | // url.Parse returns a wrapped error that contains also the URL. 27 | // Instead, we return only the error. 28 | return nil, uerr.Err 29 | } 30 | return nil, errors.New("invalid URL") 31 | } 32 | return parsedUrl, nil 33 | } 34 | 35 | // ParseGitPseudoURL attempts to parse rawURL as a git remote URL compatible with the 36 | // Github naming conventions. 37 | // 38 | // It supports the following types of git pseudo URLs: 39 | // - ssh: git@github.com:Pix4D/cogito.git; will be rewritten to the valid URL 40 | // ssh://git@github.com/Pix4D/cogito.git 41 | // - https: https://github.com/Pix4D/cogito.git 42 | // - https with u:p: https//username:password@github.com/Pix4D/cogito.git 43 | // - http: http://github.com/Pix4D/cogito.git 44 | // - http with u:p: http://username:password@github.com/Pix4D/cogito.git 45 | func ParseGitPseudoURL(rawURL string) (GitURL, error) { 46 | workURL := rawURL 47 | // If ssh pseudo URL, we need to massage the rawURL ourselves :-( 48 | if strings.HasPrefix(workURL, "git@") { 49 | if strings.Count(workURL, ":") != 1 { 50 | return GitURL{}, fmt.Errorf("invalid git SSH URL %s: want exactly one ':'", rawURL) 51 | } 52 | // Make the URL a real URL, ready to be parsed. For example: 53 | // git@github.com:Pix4D/cogito.git -> ssh://git@github.com/Pix4D/cogito.git 54 | workURL = "ssh://" + strings.Replace(workURL, ":", "/", 1) 55 | } 56 | 57 | anyUrl, err := safeUrlParse(workURL) 58 | if err != nil { 59 | return GitURL{}, err 60 | } 61 | 62 | scheme := anyUrl.Scheme 63 | if scheme == "" { 64 | return GitURL{}, fmt.Errorf("invalid git URL %s: missing scheme", rawURL) 65 | } 66 | if scheme != "ssh" && scheme != "http" && scheme != "https" { 67 | return GitURL{}, fmt.Errorf("invalid git URL %s: invalid scheme: %s", rawURL, scheme) 68 | } 69 | 70 | // Further parse the path component of the URL to see if it complies with the GitHub 71 | // naming conventions. 72 | // Example of compliant path: github.com/Pix4D/cogito.git 73 | tokens := strings.Split(anyUrl.Path, "/") 74 | if have, want := len(tokens), 3; have != want { 75 | return GitURL{}, 76 | fmt.Errorf("invalid git URL: path: want: %d components; have: %d %s", 77 | want, have, tokens) 78 | } 79 | 80 | owner := tokens[1] 81 | repo := strings.TrimSuffix(tokens[2], ".git") 82 | // All OK. Fill our gitURL struct 83 | gu := GitURL{ 84 | URL: anyUrl, 85 | Owner: owner, 86 | Repo: repo, 87 | FullName: owner + "/" + repo, 88 | } 89 | return gu, nil 90 | } 91 | -------------------------------------------------------------------------------- /github/url_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestParseGitPseudoURLSuccess(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | inURL string 15 | wantGU GitURL 16 | }{ 17 | { 18 | name: "valid SSH URL", 19 | inURL: "git@github.com:Pix4D/cogito.git", 20 | wantGU: GitURL{ 21 | URL: &url.URL{ 22 | Scheme: "ssh", 23 | User: url.User("git"), 24 | Host: "github.com", 25 | Path: "/Pix4D/cogito.git", 26 | }, 27 | Owner: "Pix4D", 28 | Repo: "cogito", 29 | FullName: "Pix4D/cogito", 30 | }, 31 | }, 32 | { 33 | name: "valid HTTPS URL", 34 | inURL: "https://github.com/Pix4D/cogito.git", 35 | wantGU: GitURL{ 36 | URL: &url.URL{ 37 | Scheme: "https", 38 | Host: "github.com", 39 | Path: "/Pix4D/cogito.git", 40 | }, 41 | Owner: "Pix4D", 42 | Repo: "cogito", 43 | FullName: "Pix4D/cogito", 44 | }, 45 | }, 46 | { 47 | name: "valid HTTP URL", 48 | inURL: "http://github.com/Pix4D/cogito.git", 49 | wantGU: GitURL{ 50 | URL: &url.URL{ 51 | Scheme: "http", 52 | Host: "github.com", 53 | Path: "/Pix4D/cogito.git", 54 | }, 55 | Owner: "Pix4D", 56 | Repo: "cogito", 57 | FullName: "Pix4D/cogito", 58 | }, 59 | }, 60 | { 61 | name: "valid HTTPS URL with username:password", 62 | inURL: "https://username:password@github.com/Pix4D/cogito.git", 63 | wantGU: GitURL{ 64 | URL: &url.URL{ 65 | Scheme: "https", 66 | User: url.UserPassword("username", "password"), 67 | Host: "github.com", 68 | Path: "/Pix4D/cogito.git", 69 | }, 70 | Owner: "Pix4D", 71 | Repo: "cogito", 72 | FullName: "Pix4D/cogito", 73 | }, 74 | }, 75 | { 76 | name: "valid HTTP URL with username:password", 77 | inURL: "http://username:password@github.com/Pix4D/cogito.git", 78 | wantGU: GitURL{ 79 | URL: &url.URL{ 80 | Scheme: "http", 81 | User: url.UserPassword("username", "password"), 82 | Host: "github.com", 83 | Path: "/Pix4D/cogito.git", 84 | }, 85 | Owner: "Pix4D", 86 | Repo: "cogito", 87 | FullName: "Pix4D/cogito", 88 | }, 89 | }, 90 | } 91 | 92 | for _, tc := range testCases { 93 | t.Run(tc.name, func(t *testing.T) { 94 | gitUrl, err := ParseGitPseudoURL(tc.inURL) 95 | 96 | if err != nil { 97 | t.Fatalf("\nhave: %s\nwant: ", err) 98 | } 99 | if diff := cmp.Diff(tc.wantGU, gitUrl, cmp.Comparer( 100 | func(x, y *url.Userinfo) bool { 101 | return x.String() == y.String() 102 | })); diff != "" { 103 | t.Errorf("gitURL: (-want +have):\n%s", diff) 104 | } 105 | }) 106 | } 107 | } 108 | 109 | func TestParseGitPseudoURLFailure(t *testing.T) { 110 | testCases := []struct { 111 | name string 112 | inURL string 113 | wantErr string 114 | }{ 115 | { 116 | name: "totally invalid URL", 117 | inURL: "hello", 118 | wantErr: "invalid git URL hello: missing scheme", 119 | }, 120 | { 121 | name: "invalid SSH URL", 122 | inURL: "git@github.com/Pix4D/cogito.git", 123 | wantErr: "invalid git SSH URL git@github.com/Pix4D/cogito.git: want exactly one ':'", 124 | }, 125 | { 126 | name: "invalid HTTPS URL", 127 | inURL: "https://github.com:Pix4D/cogito.git", 128 | wantErr: `invalid port ":Pix4D" after host`, 129 | }, 130 | { 131 | name: "invalid HTTP URL", 132 | inURL: "http://github.com:Pix4D/cogito.git", 133 | wantErr: `invalid port ":Pix4D" after host`, 134 | }, 135 | { 136 | name: "too few path components", 137 | inURL: "http://github.com/cogito.git", 138 | wantErr: "invalid git URL: path: want: 3 components; have: 2 [ cogito.git]", 139 | }, 140 | { 141 | name: "too many path components", 142 | inURL: "http://github.com/1/2/cogito.git", 143 | wantErr: "invalid git URL: path: want: 3 components; have: 4 [ 1 2 cogito.git]", 144 | }, 145 | { 146 | name: "No leaked password in invalid URL with username:password", 147 | inURL: "http://username:password@github.com/Pix4D/cogito.git\n", 148 | wantErr: `net/url: invalid control character in URL`, 149 | }, 150 | } 151 | 152 | for _, tc := range testCases { 153 | t.Run(tc.name, func(t *testing.T) { 154 | _, err := ParseGitPseudoURL(tc.inURL) 155 | 156 | assert.Error(t, err, tc.wantErr) 157 | }) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Pix4D/cogito 2 | 3 | go 1.23 4 | 5 | require ( 6 | dario.cat/mergo v1.0.0 7 | github.com/alexflint/go-arg v1.4.3 8 | github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 9 | github.com/golang-jwt/jwt/v5 v5.2.2 10 | github.com/google/go-cmp v0.6.0 11 | github.com/sasbury/mini v0.0.0-20181226232755-dc74af49394b 12 | gotest.tools/v3 v3.5.1 13 | ) 14 | 15 | require ( 16 | github.com/alexflint/go-scalar v1.2.0 // indirect 17 | github.com/kr/pretty v0.3.1 // indirect 18 | github.com/kr/text v0.2.0 // indirect 19 | github.com/rogpeppe/go-internal v1.12.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= 4 | github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= 5 | github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= 6 | github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= 7 | github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= 13 | github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= 14 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 15 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 19 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 20 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 21 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 22 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 26 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 27 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 28 | github.com/sasbury/mini v0.0.0-20181226232755-dc74af49394b h1:E0nPZOFGK8IsGxpckEpr2+7AZ+0DUGbf+WNfnRugYFI= 29 | github.com/sasbury/mini v0.0.0-20181226232755-dc74af49394b/go.mod h1:wYn/TPCowpxu/JFfDkzQH7E+IJGE20xICM5dNd1QN8c= 30 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 31 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 32 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 33 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 36 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 37 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 39 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 40 | -------------------------------------------------------------------------------- /googlechat/googlechat.go: -------------------------------------------------------------------------------- 1 | // Package googlechat implements the Google Chat API used by Cogito. 2 | // 3 | // See the README and CONTRIBUTING files for additional information and reference to 4 | // official documentation. 5 | package googlechat 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "log/slog" 15 | "net/http" 16 | "net/url" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | // BasicMessage is the request for a Google Chat basic message. 22 | type BasicMessage struct { 23 | Text string `json:"text"` 24 | } 25 | 26 | // MessageReply is the reply to [TextMessage]. 27 | // Compared to the full API reply, some uninteresting fields are removed. 28 | type MessageReply struct { 29 | Name string `json:"name"` // Absolute message ID. 30 | Sender MessageSender `json:"sender"` 31 | Text string `json:"text"` // The message text, as sent. 32 | Thread MessageThread `json:"thread"` 33 | Space MessageSpace `json:"space"` 34 | CreateTime time.Time `json:"createTime"` 35 | } 36 | 37 | // MessageSender is part of [MessageReply]. 38 | // Compared to the full API reply, some uninteresting fields are removed. 39 | type MessageSender struct { 40 | Name string `json:"name"` // Absolute user ID. 41 | DisplayName string `json:"displayName"` // Name of the webhook in the UI. 42 | Type string `json:"type"` // "BOT", ... 43 | } 44 | 45 | // MessageThread is part of [MessageReply]. 46 | // Compared to the full API reply, some uninteresting fields are removed. 47 | type MessageThread struct { 48 | Name string `json:"name"` // Absolute thread ID. 49 | } 50 | 51 | // MessageSpace is part of [MessageReply]. 52 | // Compared to the full API reply, some uninteresting fields are removed. 53 | type MessageSpace struct { 54 | Name string `json:"name"` // Absolute space ID. 55 | Type string `json:"type"` // "ROOM", ... 56 | Threaded bool `json:"threaded"` // Has the space been created as "threaded"? 57 | DisplayName string `json:"displayName"` // Name of the space in the UI. 58 | } 59 | 60 | // TextMessage sends the one-off message `text` with `threadKey` to webhook `theURL` and 61 | // returns an abridged response. 62 | // 63 | // Note that the Google Chat API encodes the secret in the webhook itself. 64 | // 65 | // Implementation note: if instead we need to send multiple messages, we should reuse the 66 | // http.Client, so we should add another API function to do so. 67 | // 68 | // References: 69 | // REST Resource: v1.spaces.messages 70 | // https://developers.google.com/chat/api/reference/rest 71 | // webhooks: https://developers.google.com/chat/how-tos/webhooks 72 | // payload: https://developers.google.com/chat/api/guides/message-formats/basic 73 | // threadKey: https://developers.google.com/chat/reference/rest/v1/spaces.messages/create 74 | func TextMessage( 75 | ctx context.Context, 76 | log *slog.Logger, 77 | theURL, threadKey, text string, 78 | ) (MessageReply, error) { 79 | body, err := json.Marshal(BasicMessage{Text: text}) 80 | if err != nil { 81 | return MessageReply{}, fmt.Errorf("TextMessage: %s", err) 82 | } 83 | 84 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, theURL, 85 | bytes.NewBuffer(body)) 86 | if err != nil { 87 | return MessageReply{}, 88 | fmt.Errorf("TextMessage: new request: %w", RedactErrorURL(err)) 89 | } 90 | req.Header.Set("Content-Type", "application/json; charset=UTF-8") 91 | 92 | // Encode the thread Key a URL parameter. 93 | if threadKey != "" { 94 | values := req.URL.Query() 95 | values.Set("threadKey", threadKey) 96 | req.URL.RawQuery = values.Encode() 97 | } 98 | 99 | client := &http.Client{} 100 | start := time.Now() 101 | resp, err := client.Do(req) 102 | if err != nil { 103 | return MessageReply{}, fmt.Errorf("TextMessage: send: %s", RedactErrorURL(err)) 104 | } 105 | defer resp.Body.Close() 106 | elapsed := time.Since(start) 107 | log.Debug( 108 | "http-request", 109 | "method", req.Method, 110 | "url", req.URL, 111 | "status", resp.StatusCode, 112 | "duration", elapsed, 113 | ) 114 | 115 | if resp.StatusCode != http.StatusOK { 116 | respBody, _ := io.ReadAll(resp.Body) 117 | return MessageReply{}, 118 | fmt.Errorf("TextMessage: status: %s; URL: %s; body: %s", 119 | resp.Status, RedactURL(req.URL), strings.TrimSpace(string(respBody))) 120 | } 121 | 122 | var reply MessageReply 123 | dec := json.NewDecoder(resp.Body) 124 | if err := dec.Decode(&reply); err != nil { 125 | return MessageReply{}, 126 | fmt.Errorf("HTTP status OK but failed to parse response: %s", err) 127 | } 128 | 129 | return reply, nil 130 | } 131 | 132 | // RedactURL returns a _best effort_ redacted copy of theURL. 133 | // 134 | // Use this workaround only when you are forced to use an API that encodes 135 | // secrets in the URL instead of setting them in the request header. 136 | // If you have control of the API, please never encode secrets in the URL. 137 | // 138 | // Redaction is applied as follows: 139 | // - removal of all query parameters 140 | // - removal of "username:password@" HTTP Basic Authentication 141 | // 142 | // Warning: it is still possible that the redacted URL contains secrets, for 143 | // example if the secret is encoded in the path. Don't do this. 144 | // 145 | // Taken from https://github.com/marco-m/lanterna 146 | func RedactURL(theURL *url.URL) *url.URL { 147 | copy := *theURL 148 | 149 | // remove all query parameters 150 | if copy.RawQuery != "" { 151 | copy.RawQuery = "REDACTED" 152 | } 153 | // remove password in user:password@host 154 | if _, ok := copy.User.Password(); ok { 155 | copy.User = url.UserPassword("REDACTED", "REDACTED") 156 | } 157 | 158 | return © 159 | } 160 | 161 | // RedactErrorURL returns a _best effort_ redacted copy of err. See 162 | // RedactURL for caveats and limitations. 163 | // In case err is not of type url.Error, then it returns the error untouched. 164 | // 165 | // Taken from https://github.com/marco-m/lanterna 166 | func RedactErrorURL(err error) error { 167 | var urlErr *url.Error 168 | if errors.As(err, &urlErr) { 169 | urlErr.URL = RedactURLString(urlErr.URL) 170 | return urlErr 171 | } 172 | return err 173 | } 174 | 175 | // RedactURLString returns a _best effort_ redacted copy of theURL. See 176 | // RedactURL for caveats and limitations. 177 | // In case theURL cannot be parsed, then return the parse error string. 178 | // 179 | // Taken from https://github.com/marco-m/lanterna 180 | func RedactURLString(theURL string) string { 181 | urlo, err := url.Parse(theURL) 182 | if err != nil { 183 | return err.Error() 184 | } 185 | return RedactURL(urlo).String() 186 | } 187 | -------------------------------------------------------------------------------- /googlechat/googlechat_test.go: -------------------------------------------------------------------------------- 1 | package googlechat_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "gotest.tools/v3/assert" 12 | "gotest.tools/v3/assert/cmp" 13 | 14 | "github.com/Pix4D/cogito/googlechat" 15 | "github.com/Pix4D/cogito/testhelp" 16 | ) 17 | 18 | func TestTextMessageIntegration(t *testing.T) { 19 | log := testhelp.MakeTestLog() 20 | gchatUrl := os.Getenv("COGITO_TEST_GCHAT_HOOK") 21 | if len(gchatUrl) == 0 { 22 | t.Skip("Skipping integration test. See CONTRIBUTING for how to enable.") 23 | } 24 | ts := time.Now().Format("2006-01-02 15:04:05 MST") 25 | user := os.Getenv("USER") 26 | if user == "" { 27 | user = "unknown" 28 | } 29 | threadKey := "banana-" + user 30 | text := fmt.Sprintf("%s message oink! 🐷 sent to thread %s by user %s", 31 | ts, threadKey, user) 32 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 33 | defer cancel() 34 | 35 | reply, err := googlechat.TextMessage(ctx, log, gchatUrl, threadKey, text) 36 | 37 | assert.NilError(t, err) 38 | assert.Assert(t, cmp.Contains(reply.Text, text)) 39 | } 40 | 41 | func TestRedactURL(t *testing.T) { 42 | hook := "https://chat.googleapis.com/v1/spaces/SSS/messages?key=KKK&token=TTT" 43 | want := "https://chat.googleapis.com/v1/spaces/SSS/messages?REDACTED" 44 | theURL, err := url.Parse(hook) 45 | assert.NilError(t, err) 46 | 47 | have := googlechat.RedactURL(theURL).String() 48 | 49 | assert.Equal(t, have, want) 50 | } 51 | 52 | func TestRedactString(t *testing.T) { 53 | hook := "https://chat.googleapis.com/v1/spaces/SSS/messages?key=KKK&token=TTT" 54 | want := "https://chat.googleapis.com/v1/spaces/SSS/messages?REDACTED" 55 | 56 | have := googlechat.RedactURLString(hook) 57 | 58 | assert.Equal(t, have, want) 59 | } 60 | -------------------------------------------------------------------------------- /pipelines/cogito-acceptance.yml: -------------------------------------------------------------------------------- 1 | # NOTE 2 | # This pipeline doesn't have the standard handlers (on_success, on_failure, on_error, 3 | # on_abort) because it is meant to be run by tests for cogito itself. 4 | # The handler output just adds noise to the tests. 5 | 6 | ########################################################### 7 | 8 | resource_types: 9 | 10 | - name: cogito 11 | type: registry-image 12 | check_every: 24h 13 | source: 14 | repository: pix4d/cogito 15 | tag: ((cogito-tag)) 16 | 17 | ########################################################### 18 | 19 | resources: 20 | 21 | - name: cogito 22 | type: cogito 23 | check_every: never 24 | source: 25 | log_level: debug 26 | owner: ((github-owner)) 27 | repo: ((repo-name)) 28 | access_token: ((oauth-personal-access-token)) 29 | gchat_webhook: ((gchat_webhook)) 30 | 31 | - name: cogito-gh-app 32 | type: cogito 33 | check_every: never 34 | source: 35 | log_level: debug 36 | owner: ((github-owner)) 37 | repo: ((repo-name)) 38 | github_app: 39 | client_id: ((github_app_client_id)) 40 | installation_id: ((github_app_installation_id)) 41 | private_key: ((github_app_private_key)) 42 | 43 | - name: cogito-default-log 44 | type: cogito 45 | check_every: never 46 | source: 47 | owner: ((github-owner)) 48 | repo: ((repo-name)) 49 | access_token: ((oauth-personal-access-token)) 50 | 51 | - name: cogito-notify-always 52 | type: cogito 53 | check_every: never 54 | source: 55 | log_level: debug 56 | owner: ((github-owner)) 57 | repo: ((repo-name)) 58 | access_token: ((oauth-personal-access-token)) 59 | gchat_webhook: ((gchat_webhook)) 60 | chat_notify_on_states: [abort, error, failure, pending, success] 61 | 62 | - name: cogito-chat-only 63 | type: cogito 64 | check_every: never 65 | source: 66 | log_level: debug 67 | sinks: 68 | - gchat 69 | gchat_webhook: ((gchat_webhook)) 70 | chat_notify_on_states: [abort, error, failure, pending, success] 71 | 72 | - name: target-repo.git 73 | type: git 74 | check_every: 24h 75 | source: 76 | uri: https://github.com/((github-owner))/((repo-name)).git 77 | branch: ((target-branch)) 78 | 79 | - name: cogito-repo.git 80 | type: git 81 | check_every: 24h 82 | source: 83 | uri: https://github.com/Pix4D/cogito.git 84 | branch: ((cogito-branch)) 85 | 86 | ########################################################### 87 | 88 | jobs: 89 | 90 | - name: chat-only-summary 91 | max_in_flight: 1 92 | plan: 93 | - get: target-repo.git 94 | - put: cogito-notify-always 95 | inputs: [target-repo.git] 96 | no_get: true 97 | params: 98 | state: success 99 | 100 | - name: chat-message-default 101 | max_in_flight: 1 102 | plan: 103 | - get: target-repo.git 104 | - put: cogito 105 | inputs: [target-repo.git] 106 | no_get: true 107 | params: 108 | state: success 109 | chat_message: "This is the custom chat message. Below, the default build summary:" 110 | 111 | - name: chat-message-no-summary 112 | max_in_flight: 1 113 | plan: 114 | - get: target-repo.git 115 | - put: cogito 116 | inputs: [target-repo.git] 117 | no_get: true 118 | params: 119 | state: success 120 | chat_message: "This is the custom chat message. No summary below." 121 | chat_append_summary: false 122 | 123 | - name: chat-message-file-default 124 | max_in_flight: 1 125 | plan: 126 | - get: cogito-repo.git 127 | - get: target-repo.git 128 | - task: generate-message-file 129 | file: cogito-repo.git/pipelines/tasks/generate-message-file.yml 130 | - put: cogito 131 | inputs: [target-repo.git, messagedir] 132 | no_get: true 133 | params: 134 | state: success 135 | chat_message_file: "messagedir/message.txt" 136 | 137 | - name: chat-message-only-simplest-possible 138 | max_in_flight: 1 139 | plan: 140 | - put: cogito-chat-only 141 | inputs: [] 142 | no_get: true 143 | params: 144 | chat_message: "This is the custom chat message." 145 | 146 | - name: chat-message-only-sinks-override 147 | max_in_flight: 1 148 | plan: 149 | - put: cogito 150 | inputs: [] 151 | no_get: true 152 | params: 153 | sinks: 154 | - gchat 155 | chat_message: "This is the custom chat message." 156 | 157 | - name: chat-message-only-file 158 | max_in_flight: 1 159 | plan: 160 | - get: cogito-repo.git 161 | - task: generate-message-file 162 | file: cogito-repo.git/pipelines/tasks/generate-message-file.yml 163 | - put: cogito-chat-only 164 | inputs: [messagedir] 165 | no_get: true 166 | params: 167 | chat_message_file: "messagedir/message.txt" 168 | 169 | - name: default-log 170 | max_in_flight: 1 171 | plan: 172 | - get: target-repo.git 173 | - put: cogito-default-log 174 | inputs: [target-repo.git] 175 | no_get: true 176 | params: 177 | state: success 178 | 179 | - name: cogito-gh-app-status 180 | max_in_flight: 1 181 | plan: 182 | - get: target-repo.git 183 | - put: cogito-gh-app 184 | inputs: [target-repo.git] 185 | no_get: true 186 | params: 187 | state: success 188 | -------------------------------------------------------------------------------- /pipelines/cogito.yml: -------------------------------------------------------------------------------- 1 | # Names are an homage to https://hanna-barbera.fandom.com/wiki/Cattanooga_Cats 2 | 3 | # NOTICE 4 | # In this pipeline we have two cogito resources ONLY BECAUSE this is a test pipeline! 5 | # In a real pipeline, one cogito resource is always enough. If not, please open an 6 | # issue to discuss your use case. 7 | meta: 8 | 9 | gh-status-1-handlers: &gh-status-1-handlers 10 | on_success: 11 | put: gh-status-1 12 | inputs: [repo.git] 13 | no_get: true 14 | params: {state: success} 15 | on_failure: 16 | put: gh-status-1 17 | inputs: [repo.git] 18 | no_get: true 19 | params: {state: failure} 20 | on_error: 21 | put: gh-status-1 22 | inputs: [repo.git] 23 | no_get: true 24 | params: {state: error} 25 | on_abort: 26 | put: gh-status-1 27 | inputs: [repo.git] 28 | no_get: true 29 | params: {state: abort} 30 | 31 | gh-status-2-handlers: &gh-status-2-handlers 32 | on_success: 33 | put: gh-status-2 34 | inputs: [repo.git] 35 | no_get: true 36 | params: {state: success} 37 | on_failure: 38 | put: gh-status-2 39 | inputs: [repo.git] 40 | no_get: true 41 | params: {state: failure} 42 | on_error: 43 | put: gh-status-2 44 | inputs: [repo.git] 45 | no_get: true 46 | params: {state: error} 47 | on_abort: 48 | put: gh-status-2 49 | inputs: [repo.git] 50 | no_get: true 51 | params: {state: abort} 52 | 53 | resource_types: 54 | 55 | - name: cogito 56 | type: registry-image 57 | # For production use, `24h` is a good tradeoff to get a new release with a maximum delay 58 | # of 24h. Here we parametrize it to work around a concourse bug when doing development. 59 | # See CONTRIBUTING for details 60 | check_every: ((cogito-image-check_every)) 61 | source: 62 | repository: pix4d/cogito 63 | tag: ((cogito-tag)) 64 | 65 | resources: 66 | 67 | - name: gh-status-1 68 | type: cogito 69 | # Since check is a no-op, we do not check, to reduce load on the system. 70 | check_every: never 71 | source: 72 | # Optional, for debugging only. 73 | log_level: debug 74 | owner: ((github-owner)) 75 | repo: ((repo-name)) 76 | access_token: ((oauth-personal-access-token)) 77 | gchat_webhook: ((gchat_webhook)) 78 | 79 | # See the NOTICE at the top of this file to understand why we have two cogito resources. 80 | - name: gh-status-2 81 | type: cogito 82 | # Since check is a no-op, we do not check, to reduce load on the system. 83 | check_every: never 84 | source: 85 | # Optional, for debugging only. 86 | log_level: debug 87 | owner: ((github-owner)) 88 | repo: ((repo-name)) 89 | access_token: ((oauth-personal-access-token)) 90 | gchat_webhook: ((gchat_webhook)) 91 | # These two states make sense only for testing the resource itself... 92 | chat_notify_on_states: [pending, success] 93 | 94 | - name: repo.git 95 | type: git 96 | check_every: 24h 97 | source: 98 | # If repo is public: 99 | uri: https://github.com/((github-owner))/((repo-name)).git 100 | # If repo is private: 101 | #uri: git@github.com:((github-owner))/((repo-name)).git 102 | #private_key: ((ssh-key)) 103 | branch: ((branch)) 104 | 105 | jobs: 106 | 107 | - name: autocat 108 | max_in_flight: 1 109 | <<: *gh-status-1-handlers 110 | plan: 111 | - get: repo.git 112 | trigger: true 113 | - put: gh-status-1 114 | inputs: [repo.git] 115 | no_get: true 116 | params: {state: pending} 117 | - task: will-fail 118 | config: 119 | platform: linux 120 | image_resource: 121 | type: registry-image 122 | source: { repository: alpine } 123 | run: 124 | path: /bin/false 125 | 126 | - name: motormouse 127 | max_in_flight: 1 128 | <<: *gh-status-1-handlers 129 | plan: 130 | - get: repo.git 131 | trigger: true 132 | - put: gh-status-1 133 | inputs: [repo.git] 134 | no_get: true 135 | params: {state: pending} 136 | - task: will-succeed 137 | config: 138 | platform: linux 139 | image_resource: 140 | type: registry-image 141 | source: { repository: alpine } 142 | run: 143 | path: /bin/true 144 | 145 | - name: kitty-jo 146 | max_in_flight: 1 147 | <<: *gh-status-2-handlers 148 | plan: 149 | - get: repo.git 150 | trigger: true 151 | - put: gh-status-2 152 | inputs: [repo.git] 153 | no_get: true 154 | params: 155 | state: pending 156 | gchat_webhook: ((gchat_webhook_2)) 157 | - task: task-1 158 | config: 159 | platform: linux 160 | image_resource: 161 | type: registry-image 162 | source: { repository: alpine } 163 | run: 164 | path: /bin/true 165 | - put: gh-status-2 166 | inputs: [repo.git] 167 | no_get: true 168 | params: 169 | # FIXME Here the state doesn't really make sense, because we are in the middle 170 | # of a job and there are further tasks: the state could change... 171 | state: success 172 | # Override the default build summary with the custom message. 173 | chat_message: "overriding hello from kitty-jo" 174 | - task: task-2 175 | config: 176 | platform: linux 177 | image_resource: 178 | type: registry-image 179 | source: { repository: alpine } 180 | run: 181 | path: /bin/true 182 | - put: gh-status-2 183 | inputs: [repo.git] 184 | no_get: true 185 | params: 186 | state: success 187 | # Append the default build summary to the custom message. 188 | chat_message: "appending hello from kitty-jo" 189 | -------------------------------------------------------------------------------- /pipelines/tasks/generate-message-file.yml: -------------------------------------------------------------------------------- 1 | platform: linux 2 | 3 | image_resource: 4 | type: registry-image 5 | source: { repository: alpine } 6 | 7 | outputs: 8 | - name: messagedir 9 | 10 | run: 11 | path: /bin/sh 12 | args: 13 | - -c 14 | - | 15 | set -o errexit 16 | cat << EOF > messagedir/message.txt 17 | 1 hello this is the message file for cogito 18 | 2 time now: $(date) 19 | EOF 20 | -------------------------------------------------------------------------------- /retry/backoff.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func ConstantBackoff(first bool, previous, limit time.Duration, err error) time.Duration { 8 | return min(previous, limit) 9 | } 10 | 11 | func ExponentialBackoff(first bool, previous, limit time.Duration, err error) time.Duration { 12 | if first { 13 | return previous 14 | } 15 | next := 2 * previous 16 | return min(next, limit) 17 | } 18 | -------------------------------------------------------------------------------- /retry/retry.go: -------------------------------------------------------------------------------- 1 | // Package retry implements a generic and customizable retry mechanism. 2 | // 3 | // Took some inspiration from: 4 | // - https://github.com/eapache/go-resiliency/tree/main/retrier 5 | package retry 6 | 7 | import ( 8 | "errors" 9 | "log/slog" 10 | "time" 11 | ) 12 | 13 | // Action is returned by a ClassifierFunc to indicate to Retry how to proceed. 14 | type Action int 15 | 16 | const ( 17 | // Success informs Retry that the attempt has been a success. 18 | Success Action = iota 19 | // HardFail informs Retry that the attempt has been a hard failure and 20 | // thus should abort retrying. 21 | HardFail 22 | // SoftFail informs Retry that the attempt has been a soft failure and 23 | // thus should keep retrying. 24 | SoftFail 25 | ) 26 | 27 | // Retry is the controller of the retry mechanism. 28 | // See the examples in file retry_example_test.go. 29 | type Retry struct { 30 | UpTo time.Duration // Total maximum duration of the retries. 31 | FirstDelay time.Duration // Duration of the first backoff. 32 | BackoffLimit time.Duration // Upper bound duration of a backoff. 33 | Log *slog.Logger 34 | SleepFn func(d time.Duration) // Optional; used only to override in tests. 35 | } 36 | 37 | // BackoffFunc returns the next backoff duration; called by [Retry.Do]. 38 | // You can use one of the ready-made functions [ConstantBackoff], 39 | // [ExponentialBackoff] or write your own. 40 | // Parameter err allows to optionally inspect the error that caused the retry 41 | // and return a custom delay; this can be used in special cases such as when 42 | // rate-limited with a fixed window; for an example see 43 | // [github.com/Pix4D/cogito/github.Backoff]. 44 | type BackoffFunc func(first bool, previous, limit time.Duration, err error) time.Duration 45 | 46 | // ClassifierFunc decides whether to proceed or not; called by [Retry.Do]. 47 | // Parameter err allows to inspect the error; for an example see 48 | // [github.com/Pix4D/cogito/github.Classifier] 49 | type ClassifierFunc func(err error) Action 50 | 51 | // WorkFunc does the unit of work that might fail and need to be retried; called 52 | // by [Retry.Do]. 53 | type WorkFunc func() error 54 | 55 | // Do is the loop of [Retry]. 56 | // See the examples in file retry_example_test.go. 57 | func (rtr Retry) Do( 58 | backoffFn BackoffFunc, 59 | classifierFn ClassifierFunc, 60 | workFn WorkFunc, 61 | ) error { 62 | if rtr.FirstDelay <= 0 { 63 | return errors.New("FirstDelay must be positive") 64 | } 65 | if rtr.BackoffLimit <= 0 { 66 | return errors.New("BackoffLimit must be positive") 67 | } 68 | if rtr.SleepFn == nil { 69 | rtr.SleepFn = time.Sleep 70 | } 71 | rtr.Log = rtr.Log.With("system", "retry") // FIXME maybe better constructor??? 72 | 73 | delay := rtr.FirstDelay 74 | totalDelay := 0 * time.Second 75 | 76 | for attempt := 1; ; attempt++ { 77 | err := workFn() 78 | switch classifierFn(err) { 79 | case Success: 80 | rtr.Log.Info("success", "attempt", attempt, "totalDelay", totalDelay) 81 | return err 82 | case HardFail: 83 | return err 84 | case SoftFail: 85 | delay = backoffFn(attempt == 1, delay, rtr.BackoffLimit, err) 86 | totalDelay += delay 87 | if totalDelay > rtr.UpTo { 88 | rtr.Log.Error("would wait for too long", "attempt", attempt, 89 | "delay", delay, "totalDelay", totalDelay, "UpTo", rtr.UpTo) 90 | return err 91 | } 92 | rtr.Log.Info("waiting", "attempt", attempt, "delay", delay, 93 | "totalDelay", totalDelay) 94 | rtr.SleepFn(delay) 95 | default: 96 | return errors.New("retry: internal error, please report") 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /retry/retry_example_test.go: -------------------------------------------------------------------------------- 1 | package retry_test 2 | 3 | // Go testable examples. 4 | // Any function in a test package with prefix "Example" is a "testable example". 5 | // It will be run as a test and the output must match the "Output:" in the comment. 6 | // See https://go.dev/blog/examples. 7 | 8 | import ( 9 | "errors" 10 | "fmt" 11 | "log/slog" 12 | "os" 13 | "time" 14 | 15 | "github.com/Pix4D/cogito/retry" 16 | ) 17 | 18 | func ExampleRetry() { 19 | rtr := retry.Retry{ 20 | UpTo: 5 * time.Second, 21 | FirstDelay: 1 * time.Second, 22 | BackoffLimit: 1 * time.Second, 23 | Log: slog.New(slog.NewTextHandler(os.Stdout, 24 | &slog.HandlerOptions{ReplaceAttr: removeTime})), 25 | } 26 | 27 | workFn := func() error { 28 | // Do work... 29 | // If something fails, as usual, return error. 30 | 31 | // Everything went well. 32 | return nil 33 | } 34 | classifierFn := func(err error) retry.Action { 35 | if err != nil { 36 | return retry.SoftFail 37 | } 38 | return retry.Success 39 | } 40 | 41 | err := rtr.Do(retry.ConstantBackoff, classifierFn, workFn) 42 | if err != nil { 43 | // Handle error... 44 | fmt.Println("error:", err) 45 | } 46 | 47 | // Output: 48 | // level=INFO msg=success system=retry attempt=1 totalDelay=0s 49 | } 50 | 51 | // Used in [ExampleRetry_CustomClassifier]. 52 | var ErrBananaUnavailable = errors.New("banana service unavailable") 53 | 54 | // Embedded in [BananaResponseError]. 55 | type BananaResponse struct { 56 | Amount int 57 | // In practice, more fields here... 58 | } 59 | 60 | // Used in [ExampleRetry_CustomClassifier]. 61 | type BananaResponseError struct { 62 | Response *BananaResponse 63 | // In practice, more fields here... 64 | } 65 | 66 | func (eb BananaResponseError) Error() string { 67 | return "look at my fields, there is more information there" 68 | } 69 | 70 | func Example_retryCustomClassifier() { 71 | rtr := retry.Retry{ 72 | UpTo: 30 * time.Second, 73 | FirstDelay: 2 * time.Second, 74 | BackoffLimit: 1 * time.Minute, 75 | Log: slog.New(slog.NewTextHandler(os.Stdout, 76 | &slog.HandlerOptions{ReplaceAttr: removeTime})), 77 | SleepFn: func(d time.Duration) {}, // Only for the test! 78 | } 79 | 80 | attempt := 0 81 | workFn := func() error { 82 | attempt++ 83 | if attempt == 3 { 84 | // Error wrapping is optional; we do it to show that it works also. 85 | return fmt.Errorf("workFn: %w", 86 | BananaResponseError{Response: &BananaResponse{Amount: 42}}) 87 | } 88 | if attempt < 5 { 89 | return ErrBananaUnavailable 90 | } 91 | // On 5th attempt we finally succeed. 92 | return nil 93 | } 94 | 95 | classifierFn := func(err error) retry.Action { 96 | var bananaResponseErr BananaResponseError 97 | if errors.As(err, &bananaResponseErr) { 98 | response := bananaResponseErr.Response 99 | if response.Amount == 42 { 100 | return retry.SoftFail 101 | } 102 | return retry.HardFail 103 | } 104 | if errors.Is(err, ErrBananaUnavailable) { 105 | return retry.SoftFail 106 | } 107 | if err != nil { 108 | return retry.HardFail 109 | } 110 | return retry.Success 111 | } 112 | 113 | err := rtr.Do(retry.ExponentialBackoff, classifierFn, workFn) 114 | if err != nil { 115 | // Handle error... 116 | fmt.Println("error:", err) 117 | } 118 | 119 | // Output: 120 | // level=INFO msg=waiting system=retry attempt=1 delay=2s totalDelay=2s 121 | // level=INFO msg=waiting system=retry attempt=2 delay=4s totalDelay=6s 122 | // level=INFO msg=waiting system=retry attempt=3 delay=8s totalDelay=14s 123 | // level=INFO msg=waiting system=retry attempt=4 delay=16s totalDelay=30s 124 | // level=INFO msg=success system=retry attempt=5 totalDelay=30s 125 | } 126 | 127 | // removeTime removes time-dependent attributes from log/slog records, making 128 | // the output of testable examples [1] deterministic. 129 | // [1]: https://go.dev/blog/examples 130 | func removeTime(groups []string, a slog.Attr) slog.Attr { 131 | if a.Key == slog.TimeKey { 132 | return slog.Attr{} 133 | } 134 | // if a.Key == "elapsed" { 135 | // return slog.Attr{} 136 | // } 137 | return a 138 | } 139 | -------------------------------------------------------------------------------- /retry/retry_test.go: -------------------------------------------------------------------------------- 1 | package retry_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/go-quicktest/qt" 13 | 14 | "github.com/Pix4D/cogito/retry" 15 | ) 16 | 17 | func TestRetrySuccessOnFirstAttempt(t *testing.T) { 18 | var sleeps []time.Duration 19 | rtr := retry.Retry{ 20 | UpTo: 5 * time.Second, 21 | FirstDelay: 1 * time.Second, 22 | BackoffLimit: 1 * time.Minute, 23 | Log: makeLog(), 24 | SleepFn: func(d time.Duration) { sleeps = append(sleeps, d) }, 25 | } 26 | workFn := func() error { return nil } 27 | 28 | err := rtr.Do(retry.ConstantBackoff, retryOnError, workFn) 29 | 30 | qt.Assert(t, qt.IsNil(err)) 31 | qt.Assert(t, qt.IsNil(sleeps)) 32 | } 33 | 34 | func TestRetrySuccessOnThirdAttempt(t *testing.T) { 35 | var sleeps []time.Duration 36 | rtr := retry.Retry{ 37 | UpTo: 5 * time.Second, 38 | FirstDelay: 1 * time.Second, 39 | BackoffLimit: 1 * time.Minute, 40 | Log: makeLog(), 41 | SleepFn: func(d time.Duration) { sleeps = append(sleeps, d) }, 42 | } 43 | attempt := 0 44 | workFn := func() error { 45 | attempt++ 46 | if attempt == 3 { 47 | return nil 48 | } 49 | return fmt.Errorf("attempt %d", attempt) 50 | } 51 | 52 | err := rtr.Do(retry.ConstantBackoff, retryOnError, workFn) 53 | 54 | qt.Assert(t, qt.IsNil(err)) 55 | wantSleeps := []time.Duration{time.Second, time.Second} 56 | qt.Assert(t, qt.DeepEquals(sleeps, wantSleeps)) 57 | } 58 | 59 | func TestRetryFailureRunOutOfTime(t *testing.T) { 60 | var sleeps []time.Duration 61 | rtr := retry.Retry{ 62 | UpTo: 5 * time.Second, 63 | FirstDelay: 1 * time.Second, 64 | BackoffLimit: 1 * time.Minute, 65 | Log: makeLog(), 66 | SleepFn: func(d time.Duration) { sleeps = append(sleeps, d) }, 67 | } 68 | ErrAlwaysFail := errors.New("I always fail") 69 | workFn := func() error { return ErrAlwaysFail } 70 | 71 | err := rtr.Do(retry.ConstantBackoff, retryOnError, workFn) 72 | 73 | qt.Assert(t, qt.ErrorIs(err, ErrAlwaysFail)) 74 | wantSleeps := []time.Duration{ 75 | time.Second, time.Second, time.Second, time.Second, time.Second} 76 | qt.Assert(t, qt.DeepEquals(sleeps, wantSleeps)) 77 | } 78 | 79 | func TestRetryExponentialBackOff(t *testing.T) { 80 | var sleeps []time.Duration 81 | rtr := retry.Retry{ 82 | FirstDelay: 1 * time.Second, 83 | BackoffLimit: 4 * time.Second, 84 | UpTo: 11 * time.Second, 85 | SleepFn: func(d time.Duration) { sleeps = append(sleeps, d) }, 86 | Log: makeLog(), 87 | } 88 | ErrAlwaysFail := errors.New("I always fail") 89 | workFn := func() error { return ErrAlwaysFail } 90 | 91 | err := rtr.Do(retry.ExponentialBackoff, retryOnError, workFn) 92 | 93 | qt.Assert(t, qt.ErrorIs(err, ErrAlwaysFail)) 94 | wantSleeps := []time.Duration{ 95 | time.Second, 2 * time.Second, 4 * time.Second, 4 * time.Second} 96 | qt.Assert(t, qt.DeepEquals(sleeps, wantSleeps)) 97 | } 98 | 99 | func TestRetryFailureHardFailOnSecondAttempt(t *testing.T) { 100 | var sleeps []time.Duration 101 | rtr := retry.Retry{ 102 | UpTo: 5 * time.Second, 103 | FirstDelay: 1 * time.Second, 104 | BackoffLimit: 1 * time.Minute, 105 | Log: makeLog(), 106 | SleepFn: func(d time.Duration) { sleeps = append(sleeps, d) }, 107 | } 108 | ErrUnrecoverable := errors.New("I am unrecoverable") 109 | classifierFn := func(err error) retry.Action { 110 | if errors.Is(err, ErrUnrecoverable) { 111 | return retry.HardFail 112 | } 113 | if err != nil { 114 | return retry.SoftFail 115 | } 116 | return retry.Success 117 | } 118 | attempt := 0 119 | workFn := func() error { 120 | attempt++ 121 | if attempt == 2 { 122 | return ErrUnrecoverable 123 | } 124 | return fmt.Errorf("attempt %d", attempt) 125 | } 126 | 127 | err := rtr.Do(retry.ConstantBackoff, classifierFn, workFn) 128 | 129 | qt.Assert(t, qt.ErrorIs(err, ErrUnrecoverable)) 130 | wantSleeps := []time.Duration{time.Second} 131 | qt.Assert(t, qt.DeepEquals(sleeps, wantSleeps)) 132 | } 133 | 134 | func retryOnError(err error) retry.Action { 135 | if err != nil { 136 | return retry.SoftFail 137 | } 138 | return retry.Success 139 | } 140 | 141 | func makeLog() *slog.Logger { 142 | out := io.Discard 143 | if testing.Verbose() { 144 | out = os.Stdout 145 | } 146 | return slog.New(slog.NewTextHandler(out, nil)) 147 | } 148 | -------------------------------------------------------------------------------- /sets/sets.go: -------------------------------------------------------------------------------- 1 | // Package sets is a minimal implementation of a generic set data structure. 2 | 3 | package sets 4 | 5 | import ( 6 | "cmp" 7 | "fmt" 8 | "sort" 9 | ) 10 | 11 | // Set is a minimal set that takes only ordered types: any type that supports the 12 | // operators < <= >= >. 13 | type Set[T cmp.Ordered] struct { 14 | items map[T]struct{} 15 | } 16 | 17 | // New returns an empty set with capacity size. The capacity will grow and shrink as a 18 | // stdlib map. 19 | func New[T cmp.Ordered](size int) *Set[T] { 20 | return &Set[T]{items: make(map[T]struct{}, size)} 21 | } 22 | 23 | // From returns a set from elements. 24 | func From[T cmp.Ordered](elements ...T) *Set[T] { 25 | s := New[T](len(elements)) 26 | for _, i := range elements { 27 | s.items[i] = struct{}{} 28 | } 29 | return s 30 | } 31 | 32 | // String returns a string representation of s, ordered. This allows to simply pass a 33 | // sets.Set as parameter to a function that expects a fmt.Stringer interface and obtain 34 | // a comparable string. 35 | func (s *Set[T]) String() string { 36 | return fmt.Sprint(s.OrderedList()) 37 | } 38 | 39 | func (s *Set[T]) Size() int { 40 | return len(s.items) 41 | } 42 | 43 | // OrderedList returns a slice of the elements of s, ordered. 44 | // TODO This can probably be replaced in Go 1.20 when a generics slice packages reaches 45 | // the stdlib. 46 | func (s *Set[T]) OrderedList() []T { 47 | elements := make([]T, 0, len(s.items)) 48 | for e := range s.items { 49 | elements = append(elements, e) 50 | } 51 | sort.Slice(elements, func(i, j int) bool { 52 | return elements[i] < elements[j] 53 | }) 54 | return elements 55 | } 56 | 57 | // Contains returns true if s contains item. 58 | func (s *Set[T]) Contains(item T) bool { 59 | _, found := s.items[item] 60 | return found 61 | } 62 | 63 | // Add inserts item into s. Returns true if the item was present. 64 | func (s *Set[T]) Add(item T) bool { 65 | if s.Contains(item) { 66 | return true 67 | } 68 | s.items[item] = struct{}{} 69 | return false 70 | } 71 | 72 | // Remove deletes item from s. Returns true if the item was present. 73 | func (s *Set[T]) Remove(item T) bool { 74 | if !s.Contains(item) { 75 | return false 76 | } 77 | delete(s.items, item) 78 | return true 79 | } 80 | 81 | // Difference returns a set containing the elements of s that are not in x. 82 | func (s *Set[T]) Difference(x *Set[T]) *Set[T] { 83 | result := New[T](max(0, s.Size()-x.Size())) 84 | for item := range s.items { 85 | if !x.Contains(item) { 86 | result.items[item] = struct{}{} 87 | } 88 | } 89 | return result 90 | } 91 | 92 | // Intersection returns a set containing the elements that are both in s and x. 93 | func (s *Set[T]) Intersection(x *Set[T]) *Set[T] { 94 | result := New[T](0) 95 | // loop over the smaller set (thanks to https://github.com/deckarep/golang-set) 96 | smaller := s 97 | bigger := x 98 | if smaller.Size() > bigger.Size() { 99 | smaller, bigger = bigger, smaller 100 | } 101 | for item := range smaller.items { 102 | if bigger.Contains(item) { 103 | result.items[item] = struct{}{} 104 | } 105 | } 106 | return result 107 | } 108 | 109 | // Union returns a set containing all the elements of s and x. 110 | func (s *Set[T]) Union(x *Set[T]) *Set[T] { 111 | result := New[T](max(s.Size(), x.Size())) 112 | for item := range s.items { 113 | result.items[item] = struct{}{} 114 | } 115 | for item := range x.items { 116 | result.items[item] = struct{}{} 117 | } 118 | return result 119 | } 120 | -------------------------------------------------------------------------------- /sets/sets_test.go: -------------------------------------------------------------------------------- 1 | package sets_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/go-quicktest/qt" 8 | 9 | "github.com/Pix4D/cogito/sets" 10 | ) 11 | 12 | func TestFromInt(t *testing.T) { 13 | type testCase struct { 14 | name string 15 | items []int 16 | wantSize int 17 | wantList []int 18 | wantString string 19 | } 20 | 21 | test := func(t *testing.T, tc testCase) { 22 | s := sets.From(tc.items...) 23 | sorted := s.OrderedList() 24 | 25 | qt.Assert(t, qt.Equals(s.Size(), tc.wantSize)) 26 | qt.Assert(t, qt.DeepEquals(sorted, tc.wantList)) 27 | qt.Assert(t, qt.Equals(fmt.Sprint(s), tc.wantString)) 28 | } 29 | 30 | testCases := []testCase{ 31 | { 32 | name: "nil", 33 | items: nil, 34 | wantSize: 0, 35 | wantList: []int{}, 36 | wantString: "[]", 37 | }, 38 | { 39 | name: "empty", 40 | items: []int{}, 41 | wantSize: 0, 42 | wantList: []int{}, 43 | wantString: "[]", 44 | }, 45 | { 46 | name: "non empty", 47 | items: []int{2, 3, 1}, 48 | wantSize: 3, 49 | wantList: []int{1, 2, 3}, 50 | wantString: "[1 2 3]", 51 | }, 52 | } 53 | 54 | for _, tc := range testCases { 55 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 56 | } 57 | } 58 | 59 | func TestFromString(t *testing.T) { 60 | type testCase struct { 61 | name string 62 | items []string 63 | wantSize int 64 | wantList []string 65 | wantString string 66 | } 67 | 68 | test := func(t *testing.T, tc testCase) { 69 | s := sets.From(tc.items...) 70 | sorted := s.OrderedList() 71 | 72 | qt.Assert(t, qt.Equals(s.Size(), tc.wantSize)) 73 | qt.Assert(t, qt.DeepEquals(sorted, tc.wantList)) 74 | qt.Assert(t, qt.Equals(fmt.Sprint(s), tc.wantString)) 75 | } 76 | 77 | testCases := []testCase{ 78 | { 79 | name: "non empty", 80 | items: []string{"b", "c", "a"}, 81 | wantSize: 3, 82 | wantList: []string{"a", "b", "c"}, 83 | wantString: "[a b c]", 84 | }, 85 | } 86 | 87 | for _, tc := range testCases { 88 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 89 | } 90 | } 91 | 92 | func TestDifference(t *testing.T) { 93 | type testCase struct { 94 | name string 95 | s *sets.Set[int] 96 | x *sets.Set[int] 97 | wantList []int 98 | } 99 | 100 | test := func(t *testing.T, tc testCase) { 101 | result := tc.s.Difference(tc.x) 102 | sorted := result.OrderedList() 103 | 104 | qt.Assert(t, qt.DeepEquals(sorted, tc.wantList)) 105 | } 106 | 107 | testCases := []testCase{ 108 | { 109 | name: "both empty", 110 | s: sets.From[int](), 111 | x: sets.From[int](), 112 | wantList: []int{}, 113 | }, 114 | { 115 | name: "empty x returns s", 116 | s: sets.From(1, 2, 3), 117 | x: sets.From[int](), 118 | wantList: []int{1, 2, 3}, 119 | }, 120 | { 121 | name: "nothing in common returns s", 122 | s: sets.From(1, 2, 3), 123 | x: sets.From(4, 5), 124 | wantList: []int{1, 2, 3}, 125 | }, 126 | { 127 | name: "one in common", 128 | s: sets.From(1, 2, 3), 129 | x: sets.From(4, 2), 130 | wantList: []int{1, 3}, 131 | }, 132 | { 133 | name: "all in common returns empty set", 134 | s: sets.From(1, 2, 3), 135 | x: sets.From(1, 2, 3, 12), 136 | wantList: []int{}, 137 | }, 138 | } 139 | 140 | for _, tc := range testCases { 141 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 142 | } 143 | } 144 | 145 | func TestIntersection(t *testing.T) { 146 | type testCase struct { 147 | name string 148 | s *sets.Set[int] 149 | x *sets.Set[int] 150 | wantList []int 151 | } 152 | 153 | test := func(t *testing.T, tc testCase) { 154 | result := tc.s.Intersection(tc.x) 155 | sorted := result.OrderedList() 156 | 157 | qt.Assert(t, qt.DeepEquals(sorted, tc.wantList)) 158 | } 159 | 160 | testCases := []testCase{ 161 | { 162 | name: "both empty", 163 | s: sets.From[int](), 164 | x: sets.From[int](), 165 | wantList: []int{}, 166 | }, 167 | { 168 | name: "empty x returns empty", 169 | s: sets.From(1, 2, 3), 170 | x: sets.From[int](), 171 | wantList: []int{}, 172 | }, 173 | { 174 | name: "nothing in common returns empty", 175 | s: sets.From(1, 2, 3), 176 | x: sets.From(4, 5), 177 | wantList: []int{}, 178 | }, 179 | { 180 | name: "one in common", 181 | s: sets.From(1, 2, 3), 182 | x: sets.From(4, 2), 183 | wantList: []int{2}, 184 | }, 185 | { 186 | name: "s subset of x returns s", 187 | s: sets.From(1, 2, 3), 188 | x: sets.From(1, 2, 3, 12), 189 | wantList: []int{1, 2, 3}, 190 | }, 191 | { 192 | name: "x subset of s returns x", 193 | s: sets.From(1, 2, 3, 12), 194 | x: sets.From(1, 2, 3), 195 | wantList: []int{1, 2, 3}, 196 | }, 197 | } 198 | 199 | for _, tc := range testCases { 200 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 201 | } 202 | } 203 | 204 | func TestUnion(t *testing.T) { 205 | type testCase struct { 206 | name string 207 | s *sets.Set[int] 208 | x *sets.Set[int] 209 | wantList []int 210 | } 211 | 212 | test := func(t *testing.T, tc testCase) { 213 | result := tc.s.Union(tc.x) 214 | sorted := result.OrderedList() 215 | 216 | qt.Assert(t, qt.DeepEquals(sorted, tc.wantList)) 217 | } 218 | 219 | testCases := []testCase{ 220 | { 221 | name: "both empty", 222 | s: sets.From[int](), 223 | x: sets.From[int](), 224 | wantList: []int{}, 225 | }, 226 | { 227 | name: "empty x", 228 | s: sets.From(1, 2), 229 | x: sets.From[int](), 230 | wantList: []int{1, 2}, 231 | }, 232 | { 233 | name: "empty s", 234 | s: sets.From[int](), 235 | x: sets.From(1, 2), 236 | wantList: []int{1, 2}, 237 | }, 238 | { 239 | name: "identical", 240 | s: sets.From(1, 2), 241 | x: sets.From(1, 2), 242 | wantList: []int{1, 2}, 243 | }, 244 | { 245 | name: "all different", 246 | s: sets.From(1, 3), 247 | x: sets.From(2, 4), 248 | wantList: []int{1, 2, 3, 4}, 249 | }, 250 | { 251 | name: "partial overlap", 252 | s: sets.From(1, 3), 253 | x: sets.From(3, 5), 254 | wantList: []int{1, 3, 5}, 255 | }, 256 | } 257 | 258 | for _, tc := range testCases { 259 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 260 | } 261 | } 262 | 263 | func TestRemoveFound(t *testing.T) { 264 | type testCase struct { 265 | name string 266 | items []int 267 | remove int 268 | wantList []int 269 | } 270 | 271 | test := func(t *testing.T, tc testCase) { 272 | s := sets.From(tc.items...) 273 | 274 | found := s.Remove(tc.remove) 275 | 276 | qt.Assert(t, qt.DeepEquals(s.OrderedList(), tc.wantList)) 277 | qt.Assert(t, qt.IsTrue(found)) 278 | } 279 | 280 | testCases := []testCase{ 281 | { 282 | name: "set with one element", 283 | items: []int{42}, 284 | remove: 42, 285 | wantList: []int{}, 286 | }, 287 | { 288 | name: "set with multiple elements", 289 | items: []int{-5, 100, 42}, 290 | remove: 42, 291 | wantList: []int{-5, 100}, 292 | }, 293 | } 294 | 295 | for _, tc := range testCases { 296 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 297 | } 298 | } 299 | 300 | func TestRemoveNotFound(t *testing.T) { 301 | type testCase struct { 302 | name string 303 | items []int 304 | remove int 305 | } 306 | 307 | test := func(t *testing.T, tc testCase) { 308 | s := sets.From(tc.items...) 309 | 310 | found := s.Remove(tc.remove) 311 | 312 | qt.Assert(t, qt.DeepEquals(s.OrderedList(), tc.items)) 313 | qt.Assert(t, qt.IsFalse(found)) 314 | } 315 | 316 | testCases := []testCase{ 317 | { 318 | name: "empty set", 319 | items: []int{}, 320 | remove: 42, 321 | }, 322 | { 323 | name: "non empty set", 324 | items: []int{10, 50}, 325 | remove: 42, 326 | }, 327 | } 328 | 329 | for _, tc := range testCases { 330 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 331 | } 332 | } 333 | 334 | func TestAdd(t *testing.T) { 335 | type testCase struct { 336 | name string 337 | items []int 338 | wantList []int 339 | } 340 | 341 | test := func(t *testing.T, tc testCase) { 342 | s := sets.New[int](5) 343 | for _, item := range tc.items { 344 | s.Add(item) 345 | } 346 | qt.Assert(t, qt.DeepEquals(s.OrderedList(), tc.wantList)) 347 | } 348 | 349 | testCases := []testCase{ 350 | { 351 | name: "one item", 352 | items: []int{3}, 353 | wantList: []int{3}, 354 | }, 355 | { 356 | name: "multiple items", 357 | items: []int{3, 0, 42}, 358 | wantList: []int{0, 3, 42}, 359 | }, 360 | { 361 | name: "duplicates", 362 | items: []int{10, 5, 5, 10, 1}, 363 | wantList: []int{1, 5, 10}, 364 | }, 365 | } 366 | 367 | for _, tc := range testCases { 368 | t.Run(tc.name, func(t *testing.T) { test(t, tc) }) 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /testhelp/testhelper.go: -------------------------------------------------------------------------------- 1 | package testhelp 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/json" 9 | "encoding/pem" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "os" 15 | "path" 16 | "path/filepath" 17 | "strings" 18 | "testing" 19 | "text/template" 20 | 21 | "dario.cat/mergo" 22 | "github.com/golang-jwt/jwt/v5" 23 | "gotest.tools/v3/assert" 24 | ) 25 | 26 | // Passed to template.Execute() 27 | type TemplateData map[string]string 28 | 29 | type Renamer func(string) string 30 | 31 | // If name begins with "dot.", replace with ".". Otherwise leave it alone. 32 | func DotRenamer(name string) string { 33 | return strings.Replace(name, "dot.", ".", 1) 34 | } 35 | 36 | func IdentityRenamer(name string) string { 37 | return name 38 | } 39 | 40 | // CopyDir recursively copies src directory below dst directory, with optional 41 | // transformations. 42 | // It performs the following transformations: 43 | // - Renames any directory with renamer. 44 | // - If templatedata is not empty, will consider each file ending with ".template" as a Go 45 | // template. 46 | // - If a file name contains basic Go template formatting (eg: `foo-{{.bar}}.template`), the 47 | // file will be renamed accordingly. 48 | // 49 | // It will fail if the dst directory doesn't exist. 50 | // 51 | // For example, if src directory is `foo`: 52 | // 53 | // foo 54 | // └── dot.git 55 | // └── config 56 | // 57 | // and dst directory is `bar`, src will be copied as: 58 | // 59 | // bar 60 | // └── foo 61 | // └── .git <= dot renamed 62 | // └── config 63 | func CopyDir(dst string, src string, dirRenamer Renamer, templatedata TemplateData) error { 64 | for _, dir := range []string{dst, src} { 65 | fi, err := os.Stat(dir) 66 | if err != nil { 67 | return err 68 | } 69 | if !fi.IsDir() { 70 | return fmt.Errorf("%v is not a directory", dst) 71 | } 72 | } 73 | 74 | renamedDir := dirRenamer(filepath.Base(src)) 75 | tgtDir := filepath.Join(dst, renamedDir) 76 | if err := os.MkdirAll(tgtDir, 0770); err != nil { 77 | return fmt.Errorf("making src dir: %w", err) 78 | } 79 | 80 | srcEntries, err := os.ReadDir(src) 81 | if err != nil { 82 | return err 83 | } 84 | for _, e := range srcEntries { 85 | src := filepath.Join(src, e.Name()) 86 | if e.IsDir() { 87 | if err := CopyDir(tgtDir, src, dirRenamer, templatedata); err != nil { 88 | return err 89 | } 90 | } else { 91 | name := e.Name() 92 | if len(templatedata) != 0 { 93 | // FIXME longstanding bug: we apply template processing always, also if the file 94 | // doesn't have the .template suffix! 95 | name = strings.TrimSuffix(name, ".template") 96 | // Subject the file name itself to template expansion 97 | tmpl, err := template.New("file-name").Parse(name) 98 | if err != nil { 99 | return fmt.Errorf("parsing file name as template %v: %w", src, err) 100 | } 101 | tmpl.Option("missingkey=error") 102 | buf := &bytes.Buffer{} 103 | if err := tmpl.Execute(buf, templatedata); err != nil { 104 | return fmt.Errorf("executing template file name %v with data %v: %w", 105 | src, templatedata, err) 106 | } 107 | name = buf.String() 108 | } 109 | if err := copyFile(filepath.Join(tgtDir, name), src, templatedata); err != nil { 110 | return err 111 | } 112 | } 113 | 114 | } 115 | return nil 116 | } 117 | 118 | func copyFile(dstPath string, srcPath string, templatedata TemplateData) error { 119 | srcFile, err := os.Open(srcPath) 120 | if err != nil { 121 | return fmt.Errorf("opening src file: %w", err) 122 | } 123 | defer srcFile.Close() 124 | 125 | // We want an error if the file already exists 126 | dstFile, err := os.OpenFile(dstPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0660) 127 | if err != nil { 128 | return fmt.Errorf("creating dst file: %w", err) 129 | } 130 | defer dstFile.Close() 131 | 132 | if len(templatedata) == 0 { 133 | _, err = io.Copy(dstFile, srcFile) 134 | return err 135 | } 136 | buf, err := io.ReadAll(srcFile) 137 | if err != nil { 138 | return err 139 | } 140 | tmpl, err := template.New(path.Base(srcPath)).Parse(string(buf)) 141 | if err != nil { 142 | return fmt.Errorf("parsing template %v: %w", srcPath, err) 143 | } 144 | tmpl.Option("missingkey=error") 145 | if err := tmpl.Execute(dstFile, templatedata); err != nil { 146 | return fmt.Errorf("executing template %v with data %v: %w", srcPath, templatedata, err) 147 | } 148 | return nil 149 | } 150 | 151 | // GhTestCfg contains the secrets needed to run integration tests against the 152 | // GitHub Commit Status API. 153 | type GhTestCfg struct { 154 | Token string 155 | GhAppClientID string 156 | GhAppInstallationID string 157 | GhAppPrivateKey string 158 | Owner string 159 | Repo string 160 | SHA string 161 | } 162 | 163 | // FakeTestCfg is a fake test configuration that can be used in some tests that need 164 | // configuration but don't really use any external service. 165 | var FakeTestCfg = GhTestCfg{ 166 | Token: "fakeToken", 167 | Owner: "fakeOwner", 168 | Repo: "fakeRepo", 169 | SHA: "0123456789012345678901234567890123456789", 170 | } 171 | 172 | // GitHubSecretsOrFail returns the secrets needed to run integration tests against the 173 | // GitHub Commit Status API. If the secrets are missing, GitHubSecretsOrFail fails the test. 174 | func GitHubSecretsOrFail(t *testing.T) GhTestCfg { 175 | t.Helper() 176 | 177 | return GhTestCfg{ 178 | Token: getEnvOrFail(t, "COGITO_TEST_OAUTH_TOKEN"), 179 | GhAppClientID: getEnvOrFail(t, "COGITO_TEST_GH_APP_CLIENT_ID"), 180 | GhAppInstallationID: getEnvOrFail(t, "COGITO_TEST_GH_APP_INSTALLATION_ID"), 181 | GhAppPrivateKey: getEnvOrFail(t, "COGITO_TEST_GH_APP_PRIVATE_KEY"), 182 | Owner: getEnvOrFail(t, "COGITO_TEST_REPO_OWNER"), 183 | Repo: getEnvOrFail(t, "COGITO_TEST_REPO_NAME"), 184 | SHA: getEnvOrFail(t, "COGITO_TEST_COMMIT_SHA"), 185 | } 186 | } 187 | 188 | // GChatTestCfg contains the secrets needed to run integration tests against the 189 | // Google Chat API. 190 | type GChatTestCfg struct { 191 | Hook string 192 | } 193 | 194 | // GoogleChatSecretsOrFail returns the secrets needed to run integration tests against the 195 | // Google Chat API. If the secrets are missing, GoogleChatSecretsOrFail fails the test. 196 | func GoogleChatSecretsOrFail(t *testing.T) GChatTestCfg { 197 | t.Helper() 198 | 199 | return GChatTestCfg{ 200 | Hook: getEnvOrFail(t, "COGITO_TEST_GCHAT_HOOK"), 201 | } 202 | } 203 | 204 | // getEnvOrFail returns the value of environment variable key. If key is missing, 205 | // getEnvOrFail fails the test. 206 | func getEnvOrFail(t *testing.T, key string) string { 207 | t.Helper() 208 | 209 | value := os.Getenv(key) 210 | if len(value) == 0 { 211 | t.Fatalf("Missing environment variable (see CONTRIBUTING): %s", key) 212 | } 213 | return value 214 | } 215 | 216 | // MakeGitRepoFromTestdata creates a temporary directory by rendering the templated 217 | // contents of testdataDir with values from (repoURL, commitSHA, head) and returns the 218 | // path to the directory. 219 | // 220 | // MakeGitRepoFromTestdata also renames directories of the form 'dot.git' to '.git', 221 | // thus making said directory a git repository. This allows to supply the 'dot.git' 222 | // directory as test input, avoiding the problem of having this testdata .git directory 223 | // a nested repository in the project repository. 224 | // 225 | // The temporary directory is registered for removal via t.Cleanup. 226 | // If any operation fails, makeGitRepoFromTestdata terminates the test by calling t.Fatal. 227 | func MakeGitRepoFromTestdata( 228 | t *testing.T, 229 | testdataDir string, 230 | repoURL string, 231 | commitSHA string, 232 | head string, 233 | ) string { 234 | t.Helper() 235 | dstDir, err := os.MkdirTemp("", "cogito-test-") 236 | if err != nil { 237 | t.Fatal("makeGitRepoFromTestdata: MkdirTemp", err) 238 | } 239 | 240 | t.Cleanup(func() { 241 | if err := os.RemoveAll(dstDir); err != nil { 242 | t.Fatal("makeGitRepoFromTestdata: cleanup: RemoveAll:", err) 243 | } 244 | }) 245 | 246 | // Prepare the template data. 247 | tdata := make(TemplateData) 248 | tdata["repo_url"] = repoURL 249 | tdata["commit_sha"] = commitSHA 250 | tdata["head"] = head 251 | tdata["branch_name"] = "a-branch-FIXME" 252 | 253 | err = CopyDir(dstDir, testdataDir, DotRenamer, tdata) 254 | if err != nil { 255 | t.Fatal("CopyDir:", err) 256 | } 257 | 258 | return dstDir 259 | } 260 | 261 | // SshRemote returns a GitHub SSH URL 262 | func SshRemote(hostname, owner, repo string) string { 263 | return fmt.Sprintf("git@%s:%s/%s.git", hostname, owner, repo) 264 | } 265 | 266 | // HttpsRemote returns a GitHub HTTPS URL 267 | func HttpsRemote(hostname, owner, repo string) string { 268 | return fmt.Sprintf("https://%s/%s/%s.git", hostname, owner, repo) 269 | } 270 | 271 | // HttpRemote returns a GitHub HTTP URL 272 | func HttpRemote(hostname, owner, repo string) string { 273 | return fmt.Sprintf("http://%s/%s/%s.git", hostname, owner, repo) 274 | } 275 | 276 | // ToJSON returns the JSON encoding of thing. 277 | func ToJSON(t *testing.T, thing any) []byte { 278 | t.Helper() 279 | buf, err := json.Marshal(thing) 280 | assert.NilError(t, err) 281 | return buf 282 | } 283 | 284 | // FromJSON unmarshals the JSON-encoded data into thing. 285 | func FromJSON(t *testing.T, data []byte, thing any) { 286 | t.Helper() 287 | err := json.Unmarshal(data, thing) 288 | assert.NilError(t, err) 289 | } 290 | 291 | // MergeStructs merges b into a and returns the merged copy. 292 | // Said in another way, a is the default and b is the override. 293 | // Used to express succinctly the delta in the test cases. 294 | // Since it is a test helper, it will panic in case of error. 295 | func MergeStructs[T any](a, b T) T { 296 | if err := mergo.Merge(&a, b, mergo.WithOverride); err != nil { 297 | panic(err) 298 | } 299 | return a 300 | } 301 | 302 | // FailingWriter is an io.Writer that always returns an error. 303 | type FailingWriter struct{} 304 | 305 | func (t *FailingWriter) Write([]byte) (n int, err error) { 306 | return 0, errors.New("test write error") 307 | } 308 | 309 | // GeneratePrivateKey creates a RSA Private Key of specified byte size 310 | func GeneratePrivateKey(t *testing.T, bitSize int) (*rsa.PrivateKey, error) { 311 | // Private Key generation 312 | privateKey, err := rsa.GenerateKey(rand.Reader, bitSize) 313 | assert.NilError(t, err) 314 | 315 | // Validate Private Key 316 | err = privateKey.Validate() 317 | assert.NilError(t, err) 318 | 319 | return privateKey, nil 320 | } 321 | 322 | // EncodePrivateKeyToPEM encodes Private Key from RSA to PEM format 323 | func EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte { 324 | // Get ASN.1 DER format 325 | privDER := x509.MarshalPKCS1PrivateKey(privateKey) 326 | 327 | // pem.Block 328 | privBlock := pem.Block{ 329 | Type: "RSA PRIVATE KEY", 330 | Headers: nil, 331 | Bytes: privDER, 332 | } 333 | 334 | return pem.EncodeToMemory(&privBlock) 335 | } 336 | 337 | // DecodeJWT decodes the HTTP request authorization header with the given RSA key 338 | // and returns the registered claims of the decoded token. 339 | func DecodeJWT(t *testing.T, r *http.Request, key *rsa.PrivateKey) *jwt.RegisteredClaims { 340 | token := strings.Fields(r.Header.Get("Authorization"))[1] 341 | tok, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) { 342 | if t.Header["alg"] != "RS256" { 343 | return nil, fmt.Errorf("unexpected signing method: %v, expected: %v", t.Header["alg"], "RS256") 344 | } 345 | return &key.PublicKey, nil 346 | }) 347 | assert.NilError(t, err) 348 | 349 | return tok.Claims.(*jwt.RegisteredClaims) 350 | } 351 | -------------------------------------------------------------------------------- /testhelp/testlog.go: -------------------------------------------------------------------------------- 1 | package testhelp 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | // MakeTestLog returns a *slog.Logger adapted for tests: it never reports the 11 | // timestamp and by default it discards all the output. If on the other hand 12 | // the tests are invoked in verbose mode (go test -v), then the logger will 13 | // log normally. 14 | func MakeTestLog() *slog.Logger { 15 | out := io.Discard 16 | if testing.Verbose() { 17 | out = os.Stdout 18 | } 19 | return slog.New(slog.NewTextHandler( 20 | out, 21 | &slog.HandlerOptions{ 22 | ReplaceAttr: RemoveTime, 23 | })) 24 | } 25 | 26 | // RemoveTime removes the "time" attribute from the output of a slog.Logger. 27 | func RemoveTime(groups []string, a slog.Attr) slog.Attr { 28 | if a.Key == slog.TimeKey { 29 | return slog.Attr{} 30 | } 31 | return a 32 | } 33 | -------------------------------------------------------------------------------- /testhelp/testserver.go: -------------------------------------------------------------------------------- 1 | package testhelp 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | ) 10 | 11 | // SpyHttpServer returns a very basic HTTP test server (spy). 12 | // The handler will JSON decode the request body into `request` and will set `theUrl` 13 | // to the request URL. 14 | // On JSON decode success, the handler will return to the client the HTTP status code 15 | // `successCode`. On JSON decode failure, the handler will return 418 I am a teapot. 16 | // If `reply` is not nil, the handler will send it to the client, JSON encoded. 17 | // To avoid races, call ts.Close() before reading any parameters. 18 | // 19 | // Example: 20 | // 21 | // var ghReq github.AddRequest 22 | // var URL *url.URL 23 | // ts := SpyHttpServer(&ghReq, nil, &URL, http.StatusCreated) 24 | func SpyHttpServer(request any, reply any, theUrl **url.URL, successCode int, 25 | ) *httptest.Server { 26 | // In the server we cannot use t *testing.T: it runs on a different goroutine; 27 | // instead, we return the assert error via the HTTP protocol itself. 28 | return httptest.NewServer( 29 | http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 30 | *theUrl = req.URL 31 | 32 | dec := json.NewDecoder(req.Body) 33 | if err := dec.Decode(request); err != nil { 34 | w.WriteHeader(http.StatusTeapot) 35 | fmt.Fprintln(w, "test: decoding request:", err) 36 | return 37 | } 38 | 39 | if reply == nil { 40 | w.WriteHeader(successCode) 41 | return 42 | } 43 | 44 | // Since we allow a custom success code, we must write it explicitly now. 45 | // If we didn't write it now, the first call to Write (in this case, the JSON 46 | // encoder just below) will trigger an implicit w.WriteHeader(http.StatusOK). 47 | w.WriteHeader(successCode) 48 | 49 | enc := json.NewEncoder(w) 50 | if err := enc.Encode(reply); err != nil { 51 | // Too late to write the header, we have already done it (this would 52 | // still be the case also if we didn't write a custom code!). This is 53 | // true for any language, it is not Go specific, it is the HTTP protocol. 54 | // Since this is test code, it is appropriate to panic. 55 | panic(fmt.Errorf("test: encoding response: %s", err)) 56 | } 57 | })) 58 | } 59 | --------------------------------------------------------------------------------