Add ability to tweet published reports

When a report is automatically published, if a Twitter access token is
set, a Tweet will be published with the title of the report and a link
to the full report.

Change-Id: Ife68d49c4d04a40b1a41a964225fd47dd514d819
diff --git a/.env b/.env
deleted file mode 100644
index f51bd45..0000000
--- a/.env
+++ /dev/null
@@ -1 +0,0 @@
-GOOGLE_APPLICATION_CREDENTIALS=/secret/credentials.json
diff --git a/.env.sample b/.env.sample
new file mode 100644
index 0000000..0c297ca
--- /dev/null
+++ b/.env.sample
@@ -0,0 +1,8 @@
+GOOGLE_APPLICATION_CREDENTIALS=/secret/credentials.json
+
+# You can leave the following fields empty if you don't want to publish tweets
+# when a vulnerability is published:
+TWITTER_OAUTH_TOKEN=
+TWITTER_OAUTH_TOKEN_SECRET=
+GOTWI_API_KEY=
+GOTWI_API_KEY_SECRET=
diff --git a/.gitignore b/.gitignore
index 0e6c978..7b84e7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
 secret/
+.env
diff --git a/README.md b/README.md
index 9f48535..03d6a48 100644
--- a/README.md
+++ b/README.md
@@ -30,4 +30,5 @@
    - Give it appropiate permissions in each Monorail project.
    - Create subdirectory `//secret/` and download the service accounts
    credentials JSON file to `//secret/credentials.json`.
+   - Copy the `.env.sample` file to `.env` and edit it to your liking.
    - Run `make docker-prod` and `docker-compose up -d`.
diff --git a/go.mod b/go.mod
index 8a4c24f..bb8e3b9 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@
 
 require (
 	github.com/cenkalti/backoff/v4 v4.1.3
+	github.com/michimani/gotwi v0.13.0
 	go.skia.org/infra v0.0.0-20220714212951-8117921d36db
 	golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75
 	google.golang.org/api v0.87.0
diff --git a/go.sum b/go.sum
index 65c4d88..43972fe 100644
--- a/go.sum
+++ b/go.sum
@@ -78,6 +78,7 @@
 github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -187,6 +188,9 @@
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/michimani/gotwi v0.13.0 h1:zVVkibHLpP6Q3pg1/3rzuVcb+95p4TZchViW/ZE53/4=
+github.com/michimani/gotwi v0.13.0/go.mod h1:2W7Xp7vgg7ZZFdwXYHwbr9gUCMaZc+ql+T8RpccbeQ4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -198,6 +202,7 @@
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -650,6 +655,7 @@
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/stringUtils.go b/stringUtils.go
new file mode 100644
index 0000000..791600e
--- /dev/null
+++ b/stringUtils.go
@@ -0,0 +1,23 @@
+package main
+
+func truncateStringWithEllipsis(s string, maxChars int) string {
+	if len(s) <= maxChars {
+		return s
+	}
+
+	// Find the last space within the maximum character limit
+	i := maxChars - 1
+	for ; i >= 0; i-- {
+		if s[i] == ' ' {
+			break
+		}
+	}
+
+	// If no space found, truncate to the maximum length minus the ellipsis length
+	if i < 0 {
+		return s[:maxChars-1] + "…"
+	}
+
+	// Truncate at the last space found
+	return s[:i] + "…"
+}
diff --git a/vulnzybot.go b/vulnzybot.go
index a954b35..1c10799 100644
--- a/vulnzybot.go
+++ b/vulnzybot.go
@@ -9,6 +9,9 @@
 	"time"
 
 	"github.com/cenkalti/backoff/v4"
+	"github.com/michimani/gotwi"
+	"github.com/michimani/gotwi/tweet/managetweet"
+	"github.com/michimani/gotwi/tweet/managetweet/types"
 	"go.skia.org/infra/go/monorail/v1"
 	"golang.org/x/exp/slices"
 	"google.golang.org/api/option"
@@ -34,6 +37,8 @@
 type VulnerabilityReport struct {
 	// ID of the vulnerability report/issue in Monorail
 	IssueId int64
+	// Title of the report
+	Title string
 	// Issue status
 	Status string
 	// Date that the status was modified
@@ -63,6 +68,7 @@
 func parseIssue(issue *monorail.ProtoApiPb2V1IssueWrapper) (*VulnerabilityReport, error) {
 	report := VulnerabilityReport{IssueId: issue.Id}
 
+	report.Title = issue.Title
 	report.Status = issue.Status
 
 	var err error
@@ -220,7 +226,21 @@
 	return err
 }
 
-func performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error {
+func postTweet(report *VulnerabilityReport, twtrClient *gotwi.Client, ctx context.Context) error {
+	truncatedBugTitle := truncateStringWithEllipsis(report.Title, 240-(2+1+6+1+1+20))
+	url := fmt.Sprintf("iavm.xyz/b/vulnz/%d", report.IssueId)
+	tweetText := fmt.Sprintf("🦋 b/%d %s %s", report.IssueId, truncatedBugTitle, url)
+	body := &types.CreateInput{
+		Text: gotwi.String(tweetText),
+	}
+	_, err := managetweet.Create(ctx, twtrClient, body)
+	if err == nil {
+		logIssue(fmt.Sprintf("Tweeted: %s", tweetText), report)
+	}
+	return err
+}
+
+func performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error {
 	var message string
 	if action.Reason == "fixedDeadline" {
 		deadlineAfterFixedInt := deadlineAfterFixed / (24 * time.Hour)
@@ -240,10 +260,18 @@
 	if err != nil {
 		return fmt.Errorf("Error trying to perform action type %s, for reason %s: %v", action.Type, action.Reason, err)
 	}
+
+	if twtrClient != nil && action.Reason == "disclosureDeadline" && action.Type == "disclosure" {
+		err := postTweet(report, twtrClient, ctx)
+		if err != nil {
+			return fmt.Errorf("Error trying to publish tweet: %v", err)
+		}
+	}
+
 	return nil
 }
 
-func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, ctx context.Context) error {
+func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error {
 	report, err := parseIssue(issue)
 	if err != nil {
 		return fmt.Errorf("Error parsing issue: %v", err)
@@ -252,7 +280,7 @@
 	actions := getTodayActions(report)
 
 	for _, action := range actions {
-		err = performAction(report, action, monorailSvc, ctx)
+		err = performAction(report, action, monorailSvc, twtrClient, ctx)
 		if err != nil {
 			return fmt.Errorf("Error handling %s action: %v", action.Type, err)
 		}
@@ -277,6 +305,21 @@
 		log.Fatalf("Can't create Monorail service: %v", err)
 	}
 
+	twtrToken, okToken := os.LookupEnv("TWITTER_OAUTH_TOKEN")
+	twtrSecret, okSecret := os.LookupEnv("TWITTER_OAUTH_TOKEN_SECRET")
+	var twtrClient *gotwi.Client
+	if okToken && okSecret {
+		in := &gotwi.NewClientInput{
+			AuthenticationMethod: gotwi.AuthenMethodOAuth1UserContext,
+			OAuthToken:           twtrToken,
+			OAuthTokenSecret:     twtrSecret,
+		}
+		twtrClient, err = gotwi.NewClient(in)
+		if err != nil {
+			log.Fatalf("Can't create Twitter client: %v", err)
+		}
+	}
+
 	var listResponse *monorail.ProtoApiPb2V1IssuesListResponse
 
 	exp := newRequestExponentialBackOff()
@@ -290,7 +333,7 @@
 	}
 
 	for _, issue := range listResponse.Items {
-		err := handleIssue(issue, monorailSvc, ctx)
+		err := handleIssue(issue, monorailSvc, twtrClient, ctx)
 		if err != nil {
 			log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err)
 		}