DEV Community

Cover image for Attempting to Learn Go - Issuer 02
Steve Layton
Steve Layton

Posted on • Originally published at shindakun.dev on

Attempting to Learn Go - Issuer 02

GitHub Issuer

Welcome back! Though if you haven’t read the first part, you may want to. We’re expanding on the code that we write last time. Adding in the ability to actually create new issues in our TODO repository and add them to the kanban board. Yes the most over-engineered TODO “system” is going to get an upgrade. With that out of the way, let's get right into it.


Lets Go

Our imports have expanded as we’re pulling in a bunch of bits from the standard library and a few external packages. The go-github package is going to do quite a bit of heavy lifting for us. oauth2 is coming along for the ride so we can use a GitHub personal access token to authorize our requests.

package main

import (
  "bytes"
  "context"
  "encoding/json"
  "fmt"
  "io/ioutil"
  "log"
  "net/http"
  "os"

  "github.com/google/go-github/v25/github"
  "github.com/shindakun/envy"
  "golang.org/x/oauth2"
)
Enter fullscreen mode Exit fullscreen mode

Currently, we’re setting a few constants. We may bring these up out of the code and make the environment variables in the “production” version. For local testing though it’s probably fine. The token, however, is already set as an environment variable, which should keep me from accidentally committing it to GitHub. It’s good practice to keep tokens out of the code whenever possible.

const (

  // RepoOwner is the owner of the repo we want to open an issue in
  RepoOwner = "shindakun"

  // IssueRepo is the repo we want to open this new issue in.
  IssueRepo = "to"

  // ProjectColumn is the TODO column number of the project we want to add the issue to
  ProjectColumn = 5647145
)

// Token is the GitHub Personal Access Token
var Token string

// Secret is used to validate webhook payloads
var Secret string
Enter fullscreen mode Exit fullscreen mode

Our Payload is pretty much set, we don’t need anything else from the responses for now. Our status handler will remain the same as well.

// Payload of GitHub webhook
type Payload struct {
  Action string `json:"action"`
  Issue struct {
    URL string `json:"url"`
    RepositoryURL string `json:"repository_url"`
    Number int `json:"number"`
    Title string `json:"title"`
    Body string `json:"body"`
  } `json:"issue"`
  Repository struct {
    Name string `json:"name"`
  } `json:"repository"`
}

func status(res http.ResponseWriter, req *http.Request) {
  fmt.Fprintf(res, "Hello!")
}
Enter fullscreen mode Exit fullscreen mode

The webhook handler starts off the same. But quickly deviates.

func handleWebhook(res http.ResponseWriter, req *http.Request) {
  var Payload Payload
  defer req.Body.Close()
Enter fullscreen mode Exit fullscreen mode

We take our incoming request and pass it and our Secret into github.ValidatePayload(). The X-Hub-Signature on the incoming request comes with a signature compare against our calculated signature. If it matches we’re good to go.

The HMAC hex digest of the response body. This header will be sent if the webhook is configured with a secret. The HMAC hex digest is generated using the sha1 hash function and the secret as the HMAC key.

This protects us from someone accidentally finding our endpoint and submitting requests. Sure the chances are low but why take chances. If the request doesn’t pass validation we simply return and carry on.

  p, err := github.ValidatePayload(req, []byte(Secret))
  if err != nil {
    http.Error(res, "bad request: "+err.Error(), 400)
    log.Printf("bad request: %v", err.Error())
    return
  }
Enter fullscreen mode Exit fullscreen mode

github.ValidatePayload() returns a []byte of the payload which we need to wrap in a “ReadCloser” which we can then pass to jsonNewDecoder() so we can parse the JSON object as our final Payload. Again, if anything goes wrong we’ll log the error and return. If all goes well, we pass our Payload to createNewIssue().

Update As @kunde21 points out in the comments, this really should have been re-written to use json.Unmarshall(). This does work but is a bit unsightly and likely not as performant.

  decoder := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(p)))
  err = decoder.Decode(&Payload)
  if err != nil {
    http.Error(res, "bad request: "+err.Error(), 400)
    log.Printf("bad request: %v", err.Error())
    return
  }

  err = createNewIssue(&Payload)
  if err != nil {
    log.Printf("bad request: %v", err.Error())
    return
  }
}
Enter fullscreen mode Exit fullscreen mode

createNewIssue() first starts by logging out the details of our payload. This is just for testing purposes and will be removed I think.

func createNewIssue(p *Payload) error {
  log.Printf("Creating New Issue.\n")
  log.Printf(" Name: %#v\n", p.Repository.Name)
  log.Printf(" Title: %#v\n", p.Issue.Title)
  log.Printf(" Body: %#v\n", p.Issue.Body)
  log.Printf(" URL: %#v\n", p.Issue.URL)
Enter fullscreen mode Exit fullscreen mode

First things first, we’ll get our oauth2 and GitHub client ready to go. This is as recommended by the go-github repo.

  ctx := context.Background()
  ts := oauth2.StaticTokenSource(
    &oauth2.Token{AccessToken: Token},
  )
  tc := oauth2.NewClient(ctx, ts)

  client := github.NewClient(tc)
Enter fullscreen mode Exit fullscreen mode

Now it’s time to build our new issue. I wanted the title to reflect which repo it was coming from.

[From repo] Remember to write a post

The body of the repo holds whatever was originally entered and a link back to the source repo. We then pack the title and body into github.IssueRequest and create the new issue!

  title := fmt.Sprintf("[%s] %s", p.Repository.Name, p.Issue.Title)
  body := fmt.Sprintf("%s\n%s/%s#%d", p.Issue.Body, RepoOwner, p.Repository.Name, p.Issue.Number)

  issue := &github.IssueRequest{
    Title: &title,
    Body: &body,
  }

  ish, _, err := client.Issues.Create(ctx, RepoOwner, IssueRepo, issue)
  if err != nil {
    log.Printf("error: %v", err)
    return err
  }
Enter fullscreen mode Exit fullscreen mode

We are not quite done though. I want to make sure the new issue is added to the TODO kanban board. So we take the details from the new issue, extract the issue ID number and set up a new “card” with github.ProjectCardOptions.

  id := *ish.ID
  card := &github.ProjectCardOptions{
    ContentID: id,
    ContentType: "Issue",
  }
Enter fullscreen mode Exit fullscreen mode

We aren’t too concerned with the details return from this call so we just check for an error and return if need be.

  _, _, err = client.Projects.CreateProjectCard(ctx, ProjectColumn, card)
  if err != nil {
    log.Printf("error: %v", err)
    return err
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode

And that brings us to our updated main(). We’ve added a bit of code to grab our environment variables and if not set we’ll bail out with an error.

func main() {
  log.Println("Issuer")
  var err error

  Token, err = envy.Get("GITHUBTOKEN")
  if err != nil || Token == "" {
    log.Printf("error: %v", err)
    os.Exit(1)
  }

  Secret, err = envy.Get("SECRET")
  if err != nil || Secret == "" {
    log.Printf("error: %v", err)
    os.Exit(1)
  }

  http.HandleFunc("/", status)
  http.HandleFunc("/webhook", handleWebhook)
  http.ListenAndServe(":3000", nil)
}
Enter fullscreen mode Exit fullscreen mode

Running

Alright, let's run it and make a new issue in our test “from” repo.

SECRET=TESTSECRET GITHUBTOKEN=1234567890 go run main.go
2019/06/15 11:23:32 Issuer
2019/06/15 11:24:42 Creating New Issue.
2019/06/15 11:24:42 Name: "from"
2019/06/15 11:24:42 Title: "asdfasdf"
2019/06/15 11:24:42 Body: "asdfasdfasdfasdfasdf"
2019/06/15 11:24:42 URL: "https://api.github.com/repos/shindakun/from/issues/13"
Enter fullscreen mode Exit fullscreen mode

Perfect! Now, all we need to do is throw it on a box and point our GitHub repos webhook settings at the proper URL.


Next time

That went pretty smooth! Next time I think we’ll convert this into something we can deploy on Google Cloud Functions! Which will make it much easier to deploy.

Questions and comments are welcome!


You can find the code for this and most of the other Attempting to Learn Go posts in the repo on GitHub.



This post was originally published on shindakun.dev.

Top comments (2)

Collapse
 
kunde21 profile image
Chad Kunde

Quick pointer, because it's common in learning Go.

  decoder := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(p)))
  err = decoder.Decode(&Payload)

Is better done via Unmarshal:

  err = json.Unmarshal(p, &Payload)

Decoders should be used when the incoming data is an io.Reader, to handle processing streams of data without buffering everything first. Once the data is buffered into a byte slice (by ValidatePayload), then Unmarshal is much more efficient.

Collapse
 
shindakun profile image
Steve Layton

Good call! I used decoder in the first pass of the code and didn't think to swap over to unmarshal. I'll add a note to the post. 👍