Skip to content

Commit 22aa940

Browse files
intel352claude
andcommitted
feat: expand GitHub plugin — go-github SDK, 13 new steps, github.app module
- Add google/go-github/v69 SDK dependency - Add github_sdk_client.go wrapping go-github with PAT + App auth - Add 13 new step types: gh_pr_create, gh_pr_merge, gh_pr_comment, gh_pr_review, gh_issue_create, gh_issue_close, gh_issue_label, gh_release_create, gh_release_upload, gh_repo_dispatch, gh_deployment_create, gh_secret_set, gh_graphql - Add github.app module with JWT generation and installation token management - Register all new steps and modules in plugin.go and plugin.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7ee987c commit 22aa940

19 files changed

Lines changed: 1675 additions & 8 deletions

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ require (
9797
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
9898
github.com/golobby/cast v1.3.3 // indirect
9999
github.com/google/btree v1.1.3 // indirect
100+
github.com/google/go-github/v69 v69.2.0 // indirect
100101
github.com/google/go-querystring v1.1.0 // indirect
101102
github.com/google/s2a-go v0.1.9 // indirect
102103
github.com/google/uuid v1.6.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
332332
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
333333
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
334334
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
335+
github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE=
336+
github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
335337
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
336338
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
337339
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=

internal/github_sdk_client.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package internal
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/google/go-github/v69/github"
7+
)
8+
9+
// SDKClient wraps the go-github client with auth support.
10+
type SDKClient struct {
11+
GH *github.Client
12+
}
13+
14+
// NewSDKClient creates a client authenticated with a personal access token.
15+
func NewSDKClient(token string) *SDKClient {
16+
client := github.NewClient(nil).WithAuthToken(token)
17+
return &SDKClient{GH: client}
18+
}
19+
20+
// NewSDKClientFromTransport creates a client from an http.RoundTripper (for GitHub App auth).
21+
func NewSDKClientFromTransport(rt http.RoundTripper) *SDKClient {
22+
httpClient := &http.Client{Transport: rt}
23+
client := github.NewClient(httpClient)
24+
return &SDKClient{GH: client}
25+
}

internal/module_github_app.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"crypto/x509"
7+
"encoding/pem"
8+
"fmt"
9+
"net/http"
10+
"os"
11+
"time"
12+
13+
"github.com/golang-jwt/jwt/v5"
14+
15+
sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk"
16+
)
17+
18+
// githubAppModule implements sdk.ModuleInstance.
19+
// It manages GitHub App authentication, generating installation access tokens
20+
// from an App's private key and installation ID.
21+
//
22+
// Module config:
23+
//
24+
// app_id: 12345
25+
// installation_id: 67890
26+
// private_key: "${GITHUB_APP_PRIVATE_KEY}" # PEM-encoded RSA key
27+
type githubAppModule struct {
28+
name string
29+
config githubAppConfig
30+
31+
// cached token and expiry for reuse within the valid window
32+
cachedToken string
33+
tokenExpiry time.Time
34+
}
35+
36+
type githubAppConfig struct {
37+
AppID int64 `yaml:"app_id"`
38+
InstallationID int64 `yaml:"installation_id"`
39+
PrivateKey string `yaml:"private_key"`
40+
}
41+
42+
// newGitHubAppModule parses the config map and returns a githubAppModule.
43+
func newGitHubAppModule(name string, config map[string]any) (*githubAppModule, error) {
44+
var cfg githubAppConfig
45+
46+
switch v := config["app_id"].(type) {
47+
case int:
48+
cfg.AppID = int64(v)
49+
case int64:
50+
cfg.AppID = v
51+
case float64:
52+
cfg.AppID = int64(v)
53+
}
54+
if cfg.AppID == 0 {
55+
return nil, fmt.Errorf("github.app %q: config.app_id is required", name)
56+
}
57+
58+
switch v := config["installation_id"].(type) {
59+
case int:
60+
cfg.InstallationID = int64(v)
61+
case int64:
62+
cfg.InstallationID = v
63+
case float64:
64+
cfg.InstallationID = int64(v)
65+
}
66+
if cfg.InstallationID == 0 {
67+
return nil, fmt.Errorf("github.app %q: config.installation_id is required", name)
68+
}
69+
70+
cfg.PrivateKey, _ = config["private_key"].(string)
71+
cfg.PrivateKey = os.ExpandEnv(cfg.PrivateKey)
72+
if cfg.PrivateKey == "" {
73+
return nil, fmt.Errorf("github.app %q: config.private_key is required", name)
74+
}
75+
76+
return &githubAppModule{name: name, config: cfg}, nil
77+
}
78+
79+
// Init is a no-op; the module is ready after construction.
80+
func (m *githubAppModule) Init() error { return nil }
81+
82+
// Start is a no-op.
83+
func (m *githubAppModule) Start(_ context.Context) error { return nil }
84+
85+
// Stop is a no-op.
86+
func (m *githubAppModule) Stop(_ context.Context) error { return nil }
87+
88+
// Name returns the module name.
89+
func (m *githubAppModule) Name() string { return m.name }
90+
91+
// GetInstallationToken returns a valid GitHub App installation access token,
92+
// using a cached value if it is still valid (expires in >5 minutes).
93+
func (m *githubAppModule) GetInstallationToken(ctx context.Context) (string, error) {
94+
if m.cachedToken != "" && time.Until(m.tokenExpiry) > 5*time.Minute {
95+
return m.cachedToken, nil
96+
}
97+
98+
jwtToken, err := m.generateJWT()
99+
if err != nil {
100+
return "", fmt.Errorf("generate app JWT: %w", err)
101+
}
102+
103+
client := NewSDKClient(jwtToken)
104+
token, _, err := client.GH.Apps.CreateInstallationToken(ctx, m.config.InstallationID, nil)
105+
if err != nil {
106+
return "", fmt.Errorf("create installation token: %w", err)
107+
}
108+
109+
m.cachedToken = token.GetToken()
110+
m.tokenExpiry = token.GetExpiresAt().Time
111+
return m.cachedToken, nil
112+
}
113+
114+
// generateJWT creates a short-lived JWT for GitHub App authentication.
115+
func (m *githubAppModule) generateJWT() (string, error) {
116+
key, err := parseRSAPrivateKey(m.config.PrivateKey)
117+
if err != nil {
118+
return "", err
119+
}
120+
121+
now := time.Now()
122+
claims := jwt.MapClaims{
123+
"iat": now.Add(-60 * time.Second).Unix(), // issued 60s ago to handle clock skew
124+
"exp": now.Add(10 * time.Minute).Unix(),
125+
"iss": m.config.AppID,
126+
}
127+
128+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
129+
return token.SignedString(key)
130+
}
131+
132+
// parseRSAPrivateKey decodes a PEM-encoded RSA private key.
133+
func parseRSAPrivateKey(pem_encoded string) (*rsa.PrivateKey, error) {
134+
block, _ := pem.Decode([]byte(pem_encoded))
135+
if block == nil {
136+
return nil, fmt.Errorf("failed to decode PEM block for RSA private key")
137+
}
138+
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
139+
if err != nil {
140+
// Try PKCS8 format as well.
141+
iface, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
142+
if err2 != nil {
143+
return nil, fmt.Errorf("parse RSA private key (PKCS1: %v, PKCS8: %v)", err, err2)
144+
}
145+
rsakey, ok := iface.(*rsa.PrivateKey)
146+
if !ok {
147+
return nil, fmt.Errorf("private key is not RSA")
148+
}
149+
return rsakey, nil
150+
}
151+
return key, nil
152+
}
153+
154+
// AppTransport implements http.RoundTripper for GitHub App authentication,
155+
// automatically refreshing the installation token as needed.
156+
type AppTransport struct {
157+
module *githubAppModule
158+
base http.RoundTripper
159+
}
160+
161+
// NewAppTransport creates an http.RoundTripper that uses App installation tokens.
162+
func NewAppTransport(mod *githubAppModule) *AppTransport {
163+
return &AppTransport{module: mod, base: http.DefaultTransport}
164+
}
165+
166+
// RoundTrip injects the installation token into each request.
167+
func (t *AppTransport) RoundTrip(req *http.Request) (*http.Response, error) {
168+
token, err := t.module.GetInstallationToken(req.Context())
169+
if err != nil {
170+
return nil, fmt.Errorf("get installation token: %w", err)
171+
}
172+
reqCopy := req.Clone(req.Context())
173+
reqCopy.Header.Set("Authorization", "Bearer "+token)
174+
return t.base.RoundTrip(reqCopy)
175+
}
176+
177+
// GetSDKClient returns an SDK client authenticated with this App's installation token.
178+
func (m *githubAppModule) GetSDKClient() *SDKClient {
179+
return NewSDKClientFromTransport(NewAppTransport(m))
180+
}
181+
182+
// Ensure githubAppModule satisfies sdk.ModuleInstance at compile time.
183+
var _ sdk.ModuleInstance = (*githubAppModule)(nil)

internal/plugin.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,27 @@ func NewGitHubPlugin() sdk.PluginProvider {
3232
func (p *githubPlugin) Manifest() sdk.PluginManifest {
3333
return sdk.PluginManifest{
3434
Name: "workflow-plugin-github",
35-
Version: "1.0.0",
35+
Version: "1.0.1",
3636
Author: "GoCodeAlone",
37-
Description: "GitHub integration plugin: webhook handling and GitHub Actions workflow management",
37+
Description: "GitHub integration plugin: webhook handling, GitHub Actions, PRs, issues, releases, and deployments",
3838
}
3939
}
4040

4141
// ModuleTypes returns the module type names this plugin provides.
4242
func (p *githubPlugin) ModuleTypes() []string {
43-
return []string{"git.webhook"}
43+
return []string{
44+
"git.webhook",
45+
"github.app",
46+
}
4447
}
4548

4649
// CreateModule creates a module instance of the given type.
4750
func (p *githubPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) {
4851
switch typeName {
4952
case "git.webhook":
5053
return newWebhookModule(name, config)
54+
case "github.app":
55+
return newGitHubAppModule(name, config)
5156
default:
5257
return nil, fmt.Errorf("github plugin: unknown module type %q", typeName)
5358
}
@@ -56,9 +61,28 @@ func (p *githubPlugin) CreateModule(typeName, name string, config map[string]any
5661
// StepTypes returns the step type names this plugin provides.
5762
func (p *githubPlugin) StepTypes() []string {
5863
return []string{
64+
// Existing steps
5965
"step.gh_action_trigger",
6066
"step.gh_action_status",
6167
"step.gh_create_check",
68+
// Pull request steps
69+
"step.gh_pr_create",
70+
"step.gh_pr_merge",
71+
"step.gh_pr_comment",
72+
"step.gh_pr_review",
73+
// Issue steps
74+
"step.gh_issue_create",
75+
"step.gh_issue_close",
76+
"step.gh_issue_label",
77+
// Release steps
78+
"step.gh_release_create",
79+
"step.gh_release_upload",
80+
// Repository steps
81+
"step.gh_repo_dispatch",
82+
"step.gh_deployment_create",
83+
"step.gh_secret_set",
84+
// GraphQL
85+
"step.gh_graphql",
6286
}
6387
}
6488

@@ -71,6 +95,32 @@ func (p *githubPlugin) CreateStep(typeName, name string, config map[string]any)
7195
return newActionStatusStep(name, config, nil)
7296
case "step.gh_create_check":
7397
return newCreateCheckStep(name, config, nil)
98+
case "step.gh_pr_create":
99+
return newPRCreateStep(name, config)
100+
case "step.gh_pr_merge":
101+
return newPRMergeStep(name, config)
102+
case "step.gh_pr_comment":
103+
return newPRCommentStep(name, config)
104+
case "step.gh_pr_review":
105+
return newPRReviewStep(name, config)
106+
case "step.gh_issue_create":
107+
return newIssueCreateStep(name, config)
108+
case "step.gh_issue_close":
109+
return newIssueCloseStep(name, config)
110+
case "step.gh_issue_label":
111+
return newIssueLabelStep(name, config)
112+
case "step.gh_release_create":
113+
return newReleaseCreateStep(name, config)
114+
case "step.gh_release_upload":
115+
return newReleaseUploadStep(name, config)
116+
case "step.gh_repo_dispatch":
117+
return newRepoDispatchStep(name, config)
118+
case "step.gh_deployment_create":
119+
return newDeploymentCreateStep(name, config)
120+
case "step.gh_secret_set":
121+
return newSecretSetStep(name, config)
122+
case "step.gh_graphql":
123+
return newGraphQLStep(name, config)
74124
default:
75125
return nil, fmt.Errorf("github plugin: unknown step type %q", typeName)
76126
}

0 commit comments

Comments
 (0)