Skip to content

Commit fb394d1

Browse files
authored
feature: add support for issue and release trigger (#15)
Add support for when event is triggered by issues open and release released. The example notification card is like below: ![image](https://github.com/google-github-actions/send-google-chat-webhook/assets/55097418/25cc7956-2c8c-4727-8728-37b2bd3723cf) ![image](https://github.com/google-github-actions/send-google-chat-webhook/assets/55097418/78dd6da5-dd30-4449-a5ea-da80f6dd3a60)
1 parent 26a9cf3 commit fb394d1

File tree

3 files changed

+344
-68
lines changed

3 files changed

+344
-68
lines changed

action.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,13 @@
1313
# limitations under the License.
1414

1515
name: 'send-google-chat-webhhook'
16+
description: "send message to your google chat workspace"
1617
inputs:
1718
webhook_url:
1819
description: "chat space webhook url"
19-
type: 'string'
2020
required: true
2121
mention:
2222
description: "mention people or not, format <users/user_id>"
23-
type: 'string'
2423
default: '<users/all>'
2524
required: false
2625

src/main.go

Lines changed: 145 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"context"
2020
"encoding/json"
2121
"fmt"
22+
"io"
2223
"net/http"
2324
"os"
2425
"os/signal"
@@ -29,8 +30,22 @@ import (
2930
)
3031

3132
const (
32-
githubContextEnv = "GITHUB_CONTEXT"
33-
jobContextEnv = "JOB_CONTEXT"
33+
githubContextEnvKey = "GITHUB_CONTEXT"
34+
jobContextEnvKey = "JOB_CONTEXT"
35+
githubContextRefKey = "ref"
36+
githubContextRepositoryKey = "repository"
37+
githubContextTriggeringActorKey = "triggering_actor"
38+
githubContextEventObjectActionKey = "action"
39+
githubContextEventNameKey = "event_name"
40+
githubContextEventKey = "event"
41+
githubContextEventURLKey = "html_url"
42+
githubEventContenntCreatedAtKey = "created_at"
43+
)
44+
45+
const (
46+
successHeaderIconURL = "https://github.githubassets.com/favicons/favicon.png"
47+
failureHeaderIconURL = "https://github.githubassets.com/favicons/favicon-failure.png"
48+
widgetRefIconURL = "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/quick_reference/default/48px.svg"
3449
)
3550

3651
var rootCmd = func() cli.Command {
@@ -54,7 +69,7 @@ var rootCmd = func() cli.Command {
5469

5570
type WorkflowNotificationCommand struct {
5671
cli.BaseCommand
57-
flagWebhookUrl string
72+
flagWebhookURL string
5873
}
5974

6075
func (c *WorkflowNotificationCommand) Desc() string {
@@ -77,7 +92,7 @@ func (c *WorkflowNotificationCommand) Flags() *cli.FlagSet {
7792
f.StringVar(&cli.StringVar{
7893
Name: "webhook-url",
7994
Example: "https://chat.googleapis.com/v1/spaces/<SPACE_ID>/messages?key=<KEY>&token=<TOKEN>",
80-
Target: &c.flagWebhookUrl,
95+
Target: &c.flagWebhookURL,
8196
Usage: `Webhook URL from google chat`,
8297
})
8398

@@ -95,30 +110,30 @@ func (c *WorkflowNotificationCommand) Run(ctx context.Context, args []string) er
95110
return fmt.Errorf("expected 0 arguments, got %q", args)
96111
}
97112

98-
ghJsonStr := c.GetEnv(githubContextEnv)
99-
if ghJsonStr == "" {
100-
return fmt.Errorf("environment var %s not set", githubContextEnv)
113+
ghJSONStr := c.GetEnv(githubContextEnvKey)
114+
if ghJSONStr == "" {
115+
return fmt.Errorf("environment var %s not set", githubContextEnvKey)
101116
}
102-
jobJsonStr := c.GetEnv(jobContextEnv)
103-
if jobJsonStr == "" {
104-
return fmt.Errorf("environment var %s not set", jobContextEnv)
117+
jobJSONStr := c.GetEnv(jobContextEnvKey)
118+
if jobJSONStr == "" {
119+
return fmt.Errorf("environment var %s not set", jobContextEnvKey)
105120
}
106121

107-
ghJson := map[string]any{}
108-
jobJson := map[string]any{}
109-
if err := json.Unmarshal([]byte(ghJsonStr), &ghJson); err != nil {
110-
return fmt.Errorf("failed unmarshaling %s: %w", githubContextEnv, err)
122+
ghJSON := map[string]any{}
123+
jobJSON := map[string]any{}
124+
if err := json.Unmarshal([]byte(ghJSONStr), &ghJSON); err != nil {
125+
return fmt.Errorf("failed unmarshaling %s: %w", githubContextEnvKey, err)
111126
}
112-
if err := json.Unmarshal([]byte(jobJsonStr), &jobJson); err != nil {
113-
return fmt.Errorf("failed unmarshaling %s: %w", jobContextEnv, err)
127+
if err := json.Unmarshal([]byte(jobJSONStr), &jobJSON); err != nil {
128+
return fmt.Errorf("failed unmarshaling %s: %w", jobContextEnvKey, err)
114129
}
115130

116-
b, err := generateMessageBody(ghJson, jobJson, time.Now())
131+
b, err := generateRequestBody(generateMessageBodyContent(ghJSON, jobJSON, time.Now()))
117132
if err != nil {
118133
return fmt.Errorf("failed to generate message body: %w", err)
119134
}
120135

121-
url := c.flagWebhookUrl
136+
url := c.flagWebhookURL
122137

123138
request, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(b))
124139
if err != nil {
@@ -133,7 +148,12 @@ func (c *WorkflowNotificationCommand) Run(ctx context.Context, args []string) er
133148
defer resp.Body.Close()
134149

135150
if got, want := resp.StatusCode, http.StatusOK; got != want {
136-
return fmt.Errorf("unexpected HTTP status code %d (%s)", got, http.StatusText(got))
151+
bodyBytes, err := io.ReadAll(resp.Body)
152+
if err != nil {
153+
return fmt.Errorf("failed to read")
154+
}
155+
bodyString := string(bodyBytes)
156+
return fmt.Errorf("unexpected HTTP status code %d (%s)\n got body: %s", got, http.StatusText(got), bodyString)
137157
}
138158

139159
return nil
@@ -155,73 +175,139 @@ func realMain(ctx context.Context) error {
155175
return rootCmd().Run(ctx, os.Args[1:]) //nolint:wrapcheck // Want passthrough
156176
}
157177

158-
func generateMessageBody(ghJson, jobJson map[string]any, timestamp time.Time) ([]byte, error) {
159-
timezoneLoc, _ := time.LoadLocation("America/Los_Angeles")
178+
// messageBodyContent defines the necessary fields for generating the request body.
179+
type messageBodyContent struct {
180+
title string
181+
subtitle string
182+
ref string
183+
triggeringActor string
184+
timestamp string
185+
clickURL string
186+
headerIconURL string
187+
eventName string
188+
repo string
189+
}
160190

161-
var iconUrl string
162-
switch jobJson["status"] {
163-
case "success":
164-
iconUrl = "https://github.githubassets.com/favicons/favicon.png"
191+
// generateMessageBodyContent returns messageBodyContent for generating the request body.
192+
// using currentTimestamp as a input is for easier testing on default case.
193+
func generateMessageBodyContent(ghJSON, jobJSON map[string]any, currentTimeStamp time.Time) *messageBodyContent {
194+
event, ok := ghJSON[githubContextEventKey].(map[string]any)
195+
if !ok {
196+
event = map[string]any{}
197+
}
198+
eventName := getMapFieldStringValue(ghJSON, githubContextEventNameKey)
199+
switch eventName {
200+
case "issues":
201+
issueContent, ok := event["issue"].(map[string]any)
202+
if !ok {
203+
issueContent = map[string]any{}
204+
}
205+
return &messageBodyContent{
206+
title: fmt.Sprintf("A issue is %s", getMapFieldStringValue(event, githubContextEventObjectActionKey)),
207+
subtitle: fmt.Sprintf("Issue title: <b>%s</b>", getMapFieldStringValue(issueContent, "title")),
208+
ref: getMapFieldStringValue(ghJSON, githubContextRefKey),
209+
triggeringActor: getMapFieldStringValue(ghJSON, githubContextTriggeringActorKey),
210+
timestamp: getMapFieldStringValue(issueContent, githubEventContenntCreatedAtKey),
211+
clickURL: getMapFieldStringValue(issueContent, githubContextEventURLKey),
212+
eventName: "issue",
213+
repo: getMapFieldStringValue(ghJSON, githubContextRepositoryKey),
214+
headerIconURL: successHeaderIconURL,
215+
}
216+
case "release":
217+
releaseContent, ok := event["release"].(map[string]any)
218+
if !ok {
219+
releaseContent = map[string]any{}
220+
}
221+
return &messageBodyContent{
222+
title: fmt.Sprintf("A release is %s", getMapFieldStringValue(event, githubContextEventObjectActionKey)),
223+
subtitle: fmt.Sprintf("Release name: <b>%s</b>", getMapFieldStringValue(releaseContent, "name")),
224+
ref: getMapFieldStringValue(ghJSON, githubContextRefKey),
225+
triggeringActor: getMapFieldStringValue(ghJSON, githubContextTriggeringActorKey),
226+
timestamp: getMapFieldStringValue(releaseContent, githubEventContenntCreatedAtKey),
227+
clickURL: getMapFieldStringValue(releaseContent, githubContextEventURLKey),
228+
eventName: "release",
229+
repo: getMapFieldStringValue(ghJSON, githubContextRepositoryKey),
230+
headerIconURL: successHeaderIconURL,
231+
}
165232
default:
166-
iconUrl = "https://github.githubassets.com/favicons/favicon-failure.png"
233+
res := &messageBodyContent{
234+
title: fmt.Sprintf("GitHub workflow %s", getMapFieldStringValue(jobJSON, "status")),
235+
subtitle: fmt.Sprintf("Workflow: <b>%s</b>", getMapFieldStringValue(ghJSON, "workflow")),
236+
ref: getMapFieldStringValue(ghJSON, githubContextRefKey),
237+
triggeringActor: getMapFieldStringValue(ghJSON, githubContextTriggeringActorKey),
238+
// The key for getting timestamp is different in differnet triggering event
239+
// a simple work around is using the new timestamp.
240+
timestamp: currentTimeStamp.UTC().Format(time.RFC3339),
241+
clickURL: fmt.Sprintf("https://github.com/%s/actions/runs/%s", getMapFieldStringValue(ghJSON, githubContextRepositoryKey), getMapFieldStringValue(ghJSON, "run_id")),
242+
eventName: "workflow",
243+
repo: getMapFieldStringValue(ghJSON, githubContextRepositoryKey),
244+
}
245+
v, ok := jobJSON["status"]
246+
if !ok || v == "failure" || v == "canceled" {
247+
res.headerIconURL = failureHeaderIconURL
248+
} else {
249+
res.headerIconURL = successHeaderIconURL
250+
}
251+
return res
167252
}
253+
}
168254

255+
// generateRequestBody returns the body of the request.
256+
func generateRequestBody(m *messageBodyContent) ([]byte, error) {
169257
jsonData := map[string]any{
170258
"cardsV2": map[string]any{
171259
"cardId": "createCardMessage",
172260
"card": map[string]any{
173261
"header": map[string]any{
174-
"title": fmt.Sprintf("GitHub workflow %s", jobJson["status"]),
175-
"subtitle": fmt.Sprintf("Workflow: <b>%s</b>", ghJson["workflow"]),
176-
"imageUrl": iconUrl,
262+
"title": m.title,
263+
"subtitle": m.subtitle,
264+
"imageUrl": m.headerIconURL,
177265
},
178266
"sections": []any{
179267
map[string]any{
180-
// "header": "This is the section header",
181268
"collapsible": true,
182269
"uncollapsibleWidgetsCount": 1,
183270
"widgets": []map[string]any{
184271
{
185272
"decoratedText": map[string]any{
186273
"startIcon": map[string]any{
187-
"iconUrl": "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/quick_reference/default/48px.svg",
274+
"iconUrl": widgetRefIconURL,
188275
},
189-
"text": fmt.Sprintf("<b>Ref:</b> %s", ghJson["ref"]),
276+
"text": fmt.Sprintf("<b>Repo: </b> %s", m.repo),
190277
},
191278
},
192279
{
193280
"decoratedText": map[string]any{
194281
"startIcon": map[string]any{
195-
"knownIcon": "PERSON",
282+
"iconUrl": widgetRefIconURL,
196283
},
197-
"text": fmt.Sprintf("<b>Run by:</b> %s", ghJson["triggering_actor"]),
284+
"text": fmt.Sprintf("<b>Ref: </b> %s", m.ref),
198285
},
199286
},
200287
{
201288
"decoratedText": map[string]any{
202289
"startIcon": map[string]any{
203-
"knownIcon": "CLOCK",
290+
"knownIcon": "PERSON",
204291
},
205-
"text": fmt.Sprintf("<b>Pacific:</b> %s", timestamp.In(timezoneLoc).Format(time.DateTime)),
292+
"text": fmt.Sprintf("<b>Actor: </b> %s", m.triggeringActor),
206293
},
207294
},
208295
{
209296
"decoratedText": map[string]any{
210297
"startIcon": map[string]any{
211298
"knownIcon": "CLOCK",
212299
},
213-
"text": fmt.Sprintf("<b>UTC:</b> %s", timestamp.UTC().Format(time.DateTime)),
300+
"text": fmt.Sprintf("<b>UTC: </b> %s", m.timestamp),
214301
},
215302
},
216303
{
217304
"buttonList": map[string]any{
218305
"buttons": []any{
219306
map[string]any{
220-
"text": "Open",
307+
"text": fmt.Sprintf("Open %s", m.eventName),
221308
"onClick": map[string]any{
222309
"openLink": map[string]any{
223-
"url": fmt.Sprintf("https://github.com/%s/actions/runs/%s",
224-
ghJson["repository"], ghJson["run_id"]),
310+
"url": m.clickURL,
225311
},
226312
},
227313
},
@@ -235,5 +321,23 @@ func generateMessageBody(ghJson, jobJson map[string]any, timestamp time.Time) ([
235321
},
236322
}
237323

238-
return json.Marshal(jsonData)
324+
fmt.Println(jsonData)
325+
326+
res, err := json.Marshal(jsonData)
327+
if err != nil {
328+
return nil, fmt.Errorf("error marshal jsonData: %w", err)
329+
}
330+
return res, nil
331+
}
332+
333+
// getMapFieldStringValue get value from a map[sting]any map.
334+
// And convert it into string type. Return empty if the conversion failed.
335+
// The keys should all exist as they are popluated by github, to simple the
336+
// code on unnecessary error handling, a empty string is returned.
337+
func getMapFieldStringValue(m map[string]any, key string) string {
338+
v, ok := m[key].(string)
339+
if !ok {
340+
v = ""
341+
}
342+
return v
239343
}

0 commit comments

Comments
 (0)