Skip to content

Commit 927a191

Browse files
committed
github: add support for github apps
1 parent a6cffbf commit 927a191

File tree

12 files changed

+402
-15
lines changed

12 files changed

+402
-15
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jobs:
3333
env:
3434
COGITO_TEST_OAUTH_TOKEN: ${{ secrets.COGITO_TEST_OAUTH_TOKEN }}
3535
COGITO_TEST_GCHAT_HOOK: ${{ secrets.COGITO_TEST_GCHAT_HOOK }}
36+
COGITO_TEST_GH_APP_PRIVATE_KEY: ${{ secrets.COGITO_TEST_GH_APP_PRIVATE_KEY }}
3637
- run: task docker:build
3738
- run: task docker:smoke
3839
- run: task docker:login

Taskfile.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ tasks:
5757
COGITO_TEST_REPO_OWNER: '{{default "pix4d" .COGITO_TEST_REPO_OWNER}}'
5858
COGITO_TEST_GCHAT_HOOK:
5959
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}}'
6064

6165
test:unit:
6266
desc: Run the unit tests.

cmd/cogito/main_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"net/url"
99
"path"
10+
"strconv"
1011
"strings"
1112
"testing"
1213
"testing/iotest"
@@ -134,6 +135,49 @@ func TestRunPutSuccessIntegration(t *testing.T) {
134135
`level=INFO msg="state posted successfully to chat" name=cogito.put name=gChat state=error`))
135136
}
136137

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+
googleChatCfg := testhelp.GoogleChatSecretsOrFail(t)
145+
ghAppInstallationID, err := strconv.ParseInt(gitHubCfg.GhAppInstallationID, 10, 64)
146+
assert.NilError(t, err)
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: ghAppInstallationID,
154+
PrivateKey: gitHubCfg.GhAppPrivateKey,
155+
},
156+
GChatWebHook: googleChatCfg.Hook,
157+
LogLevel: "debug",
158+
},
159+
Params: cogito.PutParams{State: cogito.StateError},
160+
}))
161+
var stdout bytes.Buffer
162+
var stderr bytes.Buffer
163+
inputDir := testhelp.MakeGitRepoFromTestdata(t, "../../cogito/testdata/one-repo/a-repo",
164+
testhelp.HttpsRemote(github.GhDefaultHostname, gitHubCfg.Owner, gitHubCfg.Repo), gitHubCfg.SHA,
165+
"ref: refs/heads/a-branch-FIXME")
166+
t.Setenv("BUILD_JOB_NAME", "TestRunPutGhAppSuccessIntegration")
167+
t.Setenv("ATC_EXTERNAL_URL", "https://cogito.invalid")
168+
t.Setenv("BUILD_PIPELINE_NAME", "the-test-pipeline")
169+
t.Setenv("BUILD_TEAM_NAME", "the-test-team")
170+
t.Setenv("BUILD_NAME", "42")
171+
172+
err = mainErr(stdin, &stdout, &stderr, []string{"out", inputDir})
173+
174+
assert.NilError(t, err, "\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String())
175+
assert.Assert(t, cmp.Contains(stderr.String(),
176+
`level=INFO msg="commit status posted successfully" name=cogito.put name=ghCommitStatus state=error`))
177+
assert.Assert(t, cmp.Contains(stderr.String(),
178+
`level=INFO msg="state posted successfully to chat" name=cogito.put name=gChat state=error`))
179+
}
180+
137181
func TestRunFailure(t *testing.T) {
138182
type testCase struct {
139183
name string

cogito/ghcommitsink.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,27 @@ func (sink GitHubCommitStatusSink) Send() error {
3636
ghState := ghAdaptState(sink.Request.Params.State)
3737
buildURL := concourseBuildURL(sink.Request.Env)
3838
context := ghMakeContext(sink.Request)
39+
server := github.ApiRoot(sink.Request.Source.GhHostname)
40+
41+
token := sink.Request.Source.AccessToken
42+
if token == "" {
43+
installationToken, err := github.GenerateInstallationToken(server, sink.Request.Source.GitHubApp)
44+
if err != nil {
45+
return err
46+
}
47+
token = installationToken
48+
}
3949

4050
target := &github.Target{
41-
Server: github.ApiRoot(sink.Request.Source.GhHostname),
51+
Server: server,
4252
Retry: retry.Retry{
4353
FirstDelay: retryFirstDelay,
4454
BackoffLimit: retryBackoffLimit,
4555
UpTo: retryUpTo,
4656
Log: sink.Log,
4757
},
4858
}
49-
commitStatus := github.NewCommitStatus(target, sink.Request.Source.AccessToken,
59+
commitStatus := github.NewCommitStatus(target, token,
5060
sink.Request.Source.Owner, sink.Request.Source.Repo, context, sink.Log)
5161
description := "Build " + sink.Request.Env.BuildName
5262

cogito/ghcommitsink_test.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cogito_test
22

33
import (
4+
"encoding/json"
5+
"fmt"
46
"net/http"
57
"net/http/httptest"
68
"net/url"
@@ -28,7 +30,7 @@ func TestSinkGitHubCommitStatusSendSuccess(t *testing.T) {
2830
Log: testhelp.MakeTestLog(),
2931
GitRef: wantGitRef,
3032
Request: cogito.PutRequest{
31-
Source: cogito.Source{GhHostname: gitHubSpyURL.Host},
33+
Source: cogito.Source{GhHostname: gitHubSpyURL.Host, AccessToken: "dummy-token"},
3234
Params: cogito.PutParams{State: wantState},
3335
Env: cogito.Environment{BuildJobName: jobName},
3436
},
@@ -43,6 +45,62 @@ func TestSinkGitHubCommitStatusSendSuccess(t *testing.T) {
4345
assert.Equal(t, ghReq.Context, wantContext)
4446
}
4547

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+
46104
func TestSinkGitHubCommitStatusSendFailure(t *testing.T) {
47105
ts := httptest.NewServer(
48106
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
@@ -55,7 +113,7 @@ func TestSinkGitHubCommitStatusSendFailure(t *testing.T) {
55113
Log: testhelp.MakeTestLog(),
56114
GitRef: "deadbeefdeadbeef",
57115
Request: cogito.PutRequest{
58-
Source: cogito.Source{GhHostname: gitHubSpyURL.Host},
116+
Source: cogito.Source{GhHostname: gitHubSpyURL.Host, AccessToken: "dummy-token"},
59117
Params: cogito.PutParams{State: cogito.StatePending},
60118
},
61119
}

cogito/protocol.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,14 @@ type Source struct {
162162
//
163163
// Mandatory
164164
//
165-
Owner string `json:"owner"`
166-
Repo string `json:"repo"`
165+
Owner string `json:"owner"`
166+
Repo string `json:"repo"`
167+
// Mandatory if not using github app auth
167168
AccessToken string `json:"access_token"` // SENSITIVE
169+
170+
// Mandatory if using github app auth
171+
GitHubApp github.GitHubApp `json:"github_app,omitempty"`
172+
168173
//
169174
// Optional
170175
//
@@ -188,6 +193,9 @@ func (src Source) String() string {
188193
fmt.Fprintf(&bld, "github_hostname: %s\n", src.GhHostname)
189194
fmt.Fprintf(&bld, "access_token: %s\n", redact(src.AccessToken))
190195
fmt.Fprintf(&bld, "gchat_webhook: %s\n", redact(src.GChatWebHook))
196+
fmt.Fprintf(&bld, "github_app.client_id: %s\n", src.GitHubApp.ClientId)
197+
fmt.Fprintf(&bld, "github_app.installation_id: %d\n", src.GitHubApp.InstallationId)
198+
fmt.Fprintf(&bld, "github_app.private_key: %s\n", redact(src.GitHubApp.PrivateKey))
191199
fmt.Fprintf(&bld, "log_level: %s\n", src.LogLevel)
192200
fmt.Fprintf(&bld, "context_prefix: %s\n", src.ContextPrefix)
193201
fmt.Fprintf(&bld, "omit_target_url: %t\n", src.OmitTargetURL)
@@ -245,7 +253,18 @@ func (src *Source) Validate() error {
245253
if src.Repo == "" {
246254
mandatory = append(mandatory, "repo")
247255
}
248-
if src.AccessToken == "" {
256+
257+
if src.GitHubApp != (github.GitHubApp{}) {
258+
if src.GitHubApp.ClientId == "" {
259+
mandatory = append(mandatory, "github_app.client_id")
260+
}
261+
if src.GitHubApp.InstallationId == 0 {
262+
mandatory = append(mandatory, "github_app.installation_id")
263+
}
264+
if src.GitHubApp.PrivateKey == "" {
265+
mandatory = append(mandatory, "github_app.private_key")
266+
}
267+
} else if src.AccessToken == "" {
249268
mandatory = append(mandatory, "access_token")
250269
}
251270
}

cogito/protocol_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"gotest.tools/v3/assert/cmp"
1313

1414
"github.com/Pix4D/cogito/cogito"
15+
"github.com/Pix4D/cogito/github"
1516
"github.com/Pix4D/cogito/testhelp"
1617
)
1718

@@ -63,6 +64,20 @@ func TestSourceValidationSuccess(t *testing.T) {
6364
return source
6465
},
6566
},
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+
},
6681
}
6782

6883
for _, tc := range testCases {
@@ -93,6 +108,29 @@ func TestSourceValidationFailure(t *testing.T) {
93108
source: cogito.Source{},
94109
wantErr: "source: missing keys: owner, repo, access_token",
95110
},
111+
{
112+
name: "missing mandatory git source keys for github app: client-id",
113+
source: cogito.Source{
114+
Owner: "the-owner",
115+
Repo: "the-repo",
116+
GitHubApp: github.GitHubApp{
117+
InstallationId: 1234,
118+
PrivateKey: "private-rsa-key",
119+
},
120+
},
121+
wantErr: "source: missing keys: github_app.client_id",
122+
},
123+
{
124+
name: "missing mandatory git source keys for github app: private key",
125+
source: cogito.Source{
126+
Owner: "the-owner",
127+
Repo: "the-repo",
128+
GitHubApp: github.GitHubApp{
129+
ClientId: "client-id",
130+
},
131+
},
132+
wantErr: "source: missing keys: github_app.installation_id, github_app.private_key",
133+
},
96134
{
97135
name: "missing mandatory gchat source key",
98136
source: cogito.Source{Sinks: []string{"gchat"}},
@@ -204,6 +242,7 @@ func TestSourcePrintLogRedaction(t *testing.T) {
204242
Repo: "the-repo",
205243
GhHostname: "github.com",
206244
AccessToken: "sensitive-the-access-token",
245+
GitHubApp: github.GitHubApp{ClientId: "client-id", InstallationId: 1234, PrivateKey: "sensitive-private-rsa-key"},
207246
GChatWebHook: "sensitive-gchat-webhook",
208247
LogLevel: "debug",
209248
ContextPrefix: "the-prefix",
@@ -217,6 +256,9 @@ repo: the-repo
217256
github_hostname: github.com
218257
access_token: ***REDACTED***
219258
gchat_webhook: ***REDACTED***
259+
github_app.client_id: client-id
260+
github_app.installation_id: 1234
261+
github_app.private_key: ***REDACTED***
220262
log_level: debug
221263
context_prefix: the-prefix
222264
omit_target_url: false
@@ -238,6 +280,9 @@ repo:
238280
github_hostname:
239281
access_token:
240282
gchat_webhook:
283+
github_app.client_id:
284+
github_app.installation_id: 0
285+
github_app.private_key:
241286
log_level:
242287
context_prefix:
243288
omit_target_url: false

0 commit comments

Comments
 (0)