commit 31f71d9f76221b65c3da3df527ce2ef274b284af
parent 7094e80e93d0d87a1e80a9594327df7f76288954
Author: Russ Cox <rsc@golang.org>
Date: Fri, 23 Sep 2016 17:20:49 +0000
issuedb: tool to sync issue data into local database
Diffstat:
| A | issuedb/dash.go | | | 227 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | issuedb/main.go | | | 616 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 843 insertions(+), 0 deletions(-)
diff --git a/issuedb/dash.go b/issuedb/dash.go
@@ -0,0 +1,227 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "os"
+ "sort"
+ "strings"
+)
+
+type action struct {
+ time string
+ op int
+ number int64
+ text string
+}
+
+const (
+ _ = iota
+ opCreate
+ opMilestone
+ opDemilestone
+ opClose
+ opReopen
+ opLabel
+ opUnlabel
+)
+
+type issueState struct {
+ createTime string
+ closeTime string
+ milestone string
+ needsInvestigation bool
+ needsFix bool
+ needsDecision bool
+ blocked bool
+ waitingForInfo bool
+}
+
+func dashActions() ([]action, int) {
+ var actions []action
+ var maxIssue int64
+ var last int64
+ for {
+ var all []History
+ if err := storage.Select(db, &all, "where RowID > ? order by RowID asc limit 100", last); err != nil {
+ log.Fatal("sql: %v", err)
+ }
+ if len(all) == 0 {
+ break
+ }
+ for _, h := range all {
+ if maxIssue < h.Issue {
+ maxIssue = h.Issue
+ }
+ switch h.Action {
+ case "issue":
+ actions = append(actions, action{h.Time, opCreate, h.Issue, ""})
+ case "milestone?", "milestoned":
+ actions = append(actions, action{h.Time, opMilestone, h.Issue, h.Text})
+ case "demilestoned":
+ actions = append(actions, action{h.Time, opDemilestone, h.Issue, h.Text})
+ case "close?", "closed":
+ actions = append(actions, action{h.Time, opClose, h.Issue, ""})
+ case "reopened":
+ actions = append(actions, action{h.Time, opReopen, h.Issue, ""})
+ case "labeled":
+ actions = append(actions, action{h.Time, opLabel, h.Issue, h.Text})
+ case "unlabeled":
+ actions = append(actions, action{h.Time, opUnlabel, h.Issue, h.Text})
+ }
+ last = h.RowID
+ }
+ }
+ sort.Stable(actionsByTime(actions))
+ return actions, int(maxIssue)
+}
+
+type actionsByTime []action
+
+func (x actionsByTime) Len() int { return len(x) }
+func (x actionsByTime) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
+func (x actionsByTime) Less(i, j int) bool { return x[i].time < x[j].time }
+
+func plot(actions []action, maxIssue int, emit func([]issueState, string)) {
+ var lastTime string
+ state := make([]issueState, maxIssue+1)
+ for _, a := range actions {
+ thisTime := a.time[:10]
+ if thisTime != lastTime {
+ if lastTime != "" {
+ emit(state, lastTime)
+ }
+ lastTime = thisTime
+ }
+ s := &state[a.number]
+ switch a.op {
+ case opCreate:
+ s.createTime = a.time
+ case opMilestone:
+ s.milestone = a.text
+ case opDemilestone:
+ if s.milestone == a.text {
+ s.milestone = ""
+ }
+ case opClose:
+ s.closeTime = a.time
+ case opReopen:
+ s.closeTime = ""
+ case opLabel, opUnlabel:
+ var setting *bool
+ switch a.text {
+ case "NeedsInvestigation":
+ setting = &s.needsInvestigation
+ case "NeedsFix":
+ setting = &s.needsFix
+ case "NeedsDecision":
+ setting = &s.needsDecision
+ case "WaitingForInfo":
+ setting = &s.waitingForInfo
+ }
+ if setting != nil {
+ *setting = a.op == opLabel
+ }
+ }
+ }
+ if lastTime != "" {
+ emit(state, lastTime)
+ }
+}
+
+const minDate = "2016-04-01"
+
+func dash() {
+ actions, maxIssue := dashActions()
+ plotRelease(actions, maxIssue, "Go1.8")
+ plotRelease(actions, maxIssue, "Go1.9")
+ plotNeeds(actions, maxIssue)
+}
+
+func plotRelease(actions []action, maxIssue int, release string) {
+ releaseEarly := release + "Early"
+ releaseMaybe := release + "Maybe"
+
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "var %sData = [", strings.Replace(release, ".", "", -1))
+ fmt.Fprintf(&buf, " ['Date', '%s', '%s', '%s']", releaseEarly, release, releaseMaybe)
+ plot(actions, maxIssue, func(issues []issueState, time string) {
+ if time < minDate {
+ return
+ }
+ var numRelease, numReleaseEarly, numReleaseMaybe int
+ for id := range issues {
+ issue := &issues[id]
+ if issue.createTime == "" || issue.closeTime != "" {
+ continue
+ }
+ switch issue.milestone {
+ case release:
+ numRelease++
+ case releaseEarly:
+ numReleaseEarly++
+ case releaseMaybe:
+ numReleaseMaybe++
+ }
+ }
+ fmt.Fprintf(&buf, ",\n [myDate(\"%s\"), %d, %d, %d]", time, numReleaseEarly, numRelease, numReleaseMaybe)
+ })
+ fmt.Fprintf(&buf, "\n];\n\n")
+ os.Stdout.Write(buf.Bytes())
+}
+
+func plotNeeds(actions []action, maxIssue int) {
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "var TriageData = [")
+ fmt.Fprintf(&buf, " ['Date', 'Triage', 'NeedsInvestigation', 'NeedsInvestigation+Waiting', 'NeedsInvestigation+Blocked', 'NeedsDecision', 'NeedsDecision+Waiting', 'NeedsDecision+Blocked', 'NeedsFix', 'NeedsFix+Waiting', 'NeedsFix+Blocked']")
+ plot(actions, maxIssue, func(issues []issueState, time string) {
+ if time < minDate {
+ return
+ }
+ const (
+ triage = iota
+ needsInvestigation
+ needsInvestigationWaitingForInfo
+ needsInvestigationBlocked
+ needsDecision
+ needsDecisionWaitingForInfo
+ needsDecisionBlocked
+ needsFix
+ needsFixWaitingForInfo
+ needsFixBlocked
+ maxCount
+ )
+ var count [maxCount]int
+ for id := range issues {
+ issue := &issues[id]
+ if issue.createTime == "" || issue.closeTime != "" {
+ continue
+ }
+ ix := triage
+ switch {
+ case issue.needsInvestigation:
+ ix = needsInvestigation
+ case issue.needsDecision:
+ ix = needsDecision
+ case issue.needsFix:
+ ix = needsFix
+ }
+ if ix != triage {
+ if issue.waitingForInfo {
+ ix += 1
+ } else if issue.blocked {
+ ix += 2
+ }
+ }
+ count[ix]++
+ }
+ fmt.Fprintf(&buf, ",\n [myDate(\"%s\")", time)
+ for _, x := range count {
+ fmt.Fprintf(&buf, ", %d", x)
+ }
+ fmt.Fprintf(&buf, "]")
+ })
+ fmt.Fprintf(&buf, "\n];\n\n")
+ os.Stdout.Write(buf.Bytes())
+}
diff --git a/issuedb/main.go b/issuedb/main.go
@@ -0,0 +1,616 @@
+package main
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "rsc.io/dbstore"
+ _ "rsc.io/sqlite"
+)
+
+// TODO: pragma journal_mode=WAL
+
+// Database tables. DO NOT CHANGE.
+
+type Auth struct {
+ Key string `dbstore:",key"`
+ ClientID string
+ ClientSecret string
+}
+
+type ProjectSync struct {
+ Name string `dbstore:",key"` // "owner/repo"
+ EventETag string
+ EventID int64
+ IssueDate string
+ CommentDate string
+ RefillID int64
+}
+
+type RawJSON struct {
+ RowID int64 `dbstore:",rowid"`
+ Project string
+ Type string
+ JSON []byte `dbstore:",blob"`
+}
+
+type History struct {
+ RowID int64 `dbstore:",rowid"`
+ Project string
+ Issue int64
+ Time string
+ Who string
+ Action string
+ Text string
+}
+
+var (
+ file = flag.String("f", os.Getenv("HOME")+"/githubissue.db", "database `file` to use")
+ storage = new(dbstore.Storage)
+ db *sql.DB
+ auth Auth
+)
+
+func usage() {
+ fmt.Fprintf(os.Stderr, `usage: issuedb [-f db] command [args]
+
+Commands are:
+
+ init <clientid> <clientsecret> (initialize new database)
+ add <owner/repo> (add new repository)
+ sync (sync repositories)
+
+The default database is $HOME/githubissue.db.
+`)
+ os.Exit(2)
+}
+
+func main() {
+ log.SetPrefix("issuedb: ")
+ log.SetFlags(0)
+
+ storage.Register(new(Auth))
+ storage.Register(new(ProjectSync))
+ storage.Register(new(RawJSON))
+ storage.Register(new(History))
+
+ flag.Usage = usage
+ flag.Parse()
+ args := flag.Args()
+ if len(args) == 0 {
+ usage()
+ }
+
+ if args[0] == "init" {
+ if len(args) != 3 {
+ fmt.Fprintf(os.Stderr, "usage: issuedb [-f db] init clientid clientsecret\n")
+ os.Exit(2)
+ }
+ _, err := os.Stat(*file)
+ if err == nil {
+ log.Fatalf("creating database: file %s already exists", *file)
+ }
+ db, err := sql.Open("sqlite3", *file)
+ if err != nil {
+ log.Fatalf("creating database: %v", err)
+ }
+ defer db.Close()
+ if err := storage.CreateTables(db); err != nil {
+ log.Fatalf("initializing database: %v", err)
+ }
+ auth = Auth{Key: "unauth", ClientID: args[1], ClientSecret: args[2]}
+ if err := storage.Insert(db, &auth); err != nil {
+ log.Fatal(err)
+ }
+ return
+ }
+
+ _, err := os.Stat(*file)
+ if err != nil {
+ log.Fatalf("opening database: %v", err)
+ }
+ db, err = sql.Open("sqlite3", *file)
+ if err != nil {
+ log.Fatalf("opening database: %v", err)
+ }
+ defer db.Close()
+
+ auth.Key = "unauth"
+ if err := storage.Read(db, &auth, "ALL"); err != nil {
+ log.Fatalf("reading database: %v", err)
+ }
+
+ // TODO: Remove or deal with better.
+ // This is here so that if we add new tables they get created in old databases.
+ // But there is nothing to recreate or expand tables in old databases.
+
+ switch args[0] {
+ default:
+ usage()
+
+ case "add":
+ if len(args) != 2 {
+ fmt.Fprintf(os.Stderr, "usage: issuedb [-f db] add owner/repo\n")
+ os.Exit(2)
+ }
+ var proj ProjectSync
+ proj.Name = args[1]
+ if err := storage.Read(db, &proj); err == nil {
+ log.Fatalf("project %s already stored in database", proj.Name)
+ }
+
+ proj.Name = args[1]
+ if err := storage.Insert(db, &proj); err != nil {
+ log.Fatalf("adding project: %v", err)
+ }
+ return
+
+ case "sync":
+ var projects []ProjectSync
+ if err := storage.Select(db, &projects, ""); err != nil {
+ log.Fatalf("reading projects: %v", err)
+ }
+ for _, proj := range projects {
+ doSync(&proj)
+ }
+
+ case "refill":
+ refill()
+
+ case "dash":
+ dash()
+ }
+}
+
+func doSync(proj *ProjectSync) {
+ println("WOULD SYNC", proj.Name)
+ syncIssueComments(proj)
+ syncIssues(proj)
+ syncIssueEvents(proj)
+}
+
+func syncIssueComments(proj *ProjectSync) {
+ downloadByDate(proj, "/issues/comments", &proj.CommentDate, "CommentDate")
+}
+
+func syncIssues(proj *ProjectSync) {
+ downloadByDate(proj, "/issues", &proj.IssueDate, "IssueDate")
+}
+
+func downloadByDate(proj *ProjectSync, api string, since *string, sinceName string) {
+ values := url.Values{
+ "client_id": {auth.ClientID},
+ "client_secret": {auth.ClientSecret},
+ "sort": {"updated"},
+ "direction": {"asc"},
+ "page": {"1"},
+ "per_page": {"100"},
+ }
+ if api == "/issues" {
+ values.Set("state", "all")
+ }
+ if api == "/issues/comments" {
+ delete(values, "per_page")
+ }
+ if *since != "" {
+ values.Set("since", *since)
+ }
+ urlStr := "https://api.github.com/repos/" + proj.Name + api + "?" + values.Encode()
+
+ err := downloadPages(urlStr, "", func(_ *http.Response, all []json.RawMessage) error {
+ tx, err := db.Begin()
+ if err != nil {
+ return fmt.Errorf("starting db transaction: %v", err)
+ }
+ defer tx.Rollback()
+ var last string
+ for _, m := range all {
+ var meta struct {
+ Updated string `json:"updated_at"`
+ }
+ if err := json.Unmarshal(m, &meta); err != nil {
+ return fmt.Errorf("parsing message: %v", err)
+ }
+ if meta.Updated == "" {
+ return fmt.Errorf("parsing message: no updated_at: %s\n", string(m))
+ }
+ last = meta.Updated
+
+ var raw RawJSON
+ raw.Project = proj.Name
+ raw.Type = api
+ raw.JSON = m
+ if err := storage.Insert(tx, &raw); err != nil {
+ return fmt.Errorf("writing JSON to database: %v", err)
+ }
+ }
+ *since = last
+ if err := storage.Write(tx, proj, sinceName); err != nil {
+ return fmt.Errorf("updating database metadata: %v", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ return nil
+ })
+
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+func syncIssueEvents(proj *ProjectSync) {
+ tx, err := db.Begin()
+ if err != nil {
+ log.Fatalf("starting db transaction: %v", err)
+ }
+ defer tx.Rollback()
+
+ values := url.Values{
+ "client_id": {auth.ClientID},
+ "client_secret": {auth.ClientSecret},
+ "page": {"1"},
+ "per_page": {"100"},
+ }
+ const api = "/issues/events"
+ urlStr := "https://api.github.com/repos/" + proj.Name + api + "?" + values.Encode()
+ var (
+ firstID int64
+ firstETag string
+ )
+ done := errors.New("DONE")
+ err = downloadPages(urlStr, proj.EventETag, func(resp *http.Response, all []json.RawMessage) error {
+ for _, m := range all {
+ var meta struct {
+ ID int64 `json:"id"`
+ }
+ if err := json.Unmarshal(m, &meta); err != nil {
+ return fmt.Errorf("parsing message: %v", err)
+ }
+ if meta.ID == 0 {
+ return fmt.Errorf("parsing message: no id: %s", string(m))
+ }
+ println(meta.ID)
+ if firstID == 0 {
+ firstID = meta.ID
+ firstETag = resp.Header.Get("Etag")
+ }
+ if proj.EventID != 0 && meta.ID <= proj.EventID {
+ return done
+ }
+
+ var raw RawJSON
+ raw.Project = proj.Name
+ raw.Type = api
+ raw.JSON = m
+ if err := storage.Insert(tx, &raw); err != nil {
+ return fmt.Errorf("writing JSON to database: %v", err)
+ }
+ }
+ return nil
+ })
+ if err == done {
+ err = nil
+ }
+ if err != nil {
+ log.Fatalf("syncing events: %v", err)
+ }
+
+ if firstID != 0 {
+ proj.EventID = firstID
+ proj.EventETag = firstETag
+ if err := storage.Write(tx, proj, "EventID", "EventETag"); err != nil {
+ log.Fatalf("updating database metadata: %v", err)
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func downloadPages(url, etag string, do func(*http.Response, []json.RawMessage) error) error {
+ nfail := 0
+ for n := 0; url != ""; n++ {
+ again:
+ println("URL:", url)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return err
+ }
+ if etag != "" {
+ req.Header.Set("If-None-Match", etag)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ println("RESP:", js(resp.Header))
+
+ data, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("reading body: %v", err)
+ }
+ if resp.StatusCode != 200 {
+ if resp.StatusCode == 500 {
+ nfail++
+ if nfail < 2 {
+ time.Sleep(time.Duration(nfail) * 2 * time.Second)
+ goto again
+ }
+ }
+ return fmt.Errorf("%s\n%s", resp.Status, data)
+ }
+ checkRateLimit(resp)
+
+ var all []json.RawMessage
+ if err := json.Unmarshal(data, &all); err != nil {
+ return fmt.Errorf("parsing body: %v", err)
+ }
+ println("GOT", len(all), "messages")
+
+ if err := do(resp, all); err != nil {
+ return err
+ }
+
+ url = findNext(resp.Header.Get("Link"))
+ }
+ return nil
+}
+
+func findNext(link string) string {
+ for link != "" {
+ link = strings.TrimSpace(link)
+ if !strings.HasPrefix(link, "<") {
+ break
+ }
+ i := strings.Index(link, ">")
+ if i < 0 {
+ break
+ }
+ linkURL := link[1:i]
+ link = strings.TrimSpace(link[i+1:])
+ for strings.HasPrefix(link, ";") {
+ link = strings.TrimSpace(link[1:])
+ i := strings.Index(link, ";")
+ j := strings.Index(link, ",")
+ if i < 0 || j >= 0 && j < i {
+ i = j
+ }
+ if i < 0 {
+ i = len(link)
+ }
+ attr := strings.TrimSpace(link[:i])
+ if attr == `rel="next"` {
+ return linkURL
+ }
+ link = link[i:]
+ }
+ if !strings.HasPrefix(link, ",") {
+ break
+ }
+ link = strings.TrimSpace(link[1:])
+ }
+ return ""
+}
+
+func checkRateLimit(resp *http.Response) {
+ // TODO
+}
+
+func js(x interface{}) string {
+ data, err := json.MarshalIndent(x, "", "\t")
+ if err != nil {
+ return "ERROR: " + err.Error()
+ }
+ return string(data)
+}
+
+type ghIssueEvent struct {
+ Actor struct {
+ Login string `json:"login"`
+ } `json:"actor"`
+ Event string `json:"event"`
+ Label struct {
+ Name string `json:"name"`
+ } `json:"label"`
+ CreatedAt string `json:"created_at"`
+ Issue struct {
+ Number int64 `json:"number"`
+ } `json:"issue"`
+ CommitID string `json:"commit_id"`
+ Assignee struct {
+ Login string `json:"login"`
+ } `json:"assignee"`
+ Milestone struct {
+ Title string `json:"title"`
+ } `json:"milestone"`
+ Rename struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ } `json:"rename"`
+}
+
+type ghIssueComment struct {
+ IssueURL string `json:"issue_url"`
+ User struct {
+ Login string `json:"login"`
+ } `json:"user"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ Body string `json:"body"`
+}
+
+type ghIssue struct {
+ URL string `json:"url"`
+ User struct {
+ Login string `json:"login"`
+ } `json:"user"`
+ Title string `json:"title"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ Body string `json:"body"`
+ Assignee struct {
+ Login string `json:"login"`
+ } `json:"assignee"`
+ Milestone struct {
+ Title string `json:"title"`
+ } `json:"milestone"`
+ State string `json:"state"`
+}
+
+func refill() {
+ if _, err := db.Exec("delete from History"); err != nil {
+ log.Fatal(err)
+ }
+ var last int64
+ for {
+ var all []RawJSON
+ if err := storage.Select(db, &all, "where RowID > ? order by RowID asc limit 100", last); err != nil {
+ log.Fatal("sql: %v", err)
+ }
+ if len(all) == 0 {
+ break
+ }
+ println("GOT", len(all), all[0].RowID, all[len(all)-1].RowID)
+ tx, err := db.Begin()
+ if err != nil {
+ log.Fatal(err)
+ }
+ for _, m := range all {
+ last = m.RowID
+ switch m.Type {
+ default:
+ println("TYPE", m.Type)
+ case "/issues/events":
+ var ev ghIssueEvent
+ if err := json.Unmarshal(m.JSON, &ev); err != nil {
+ log.Printf("unmarshal: %v\n%s", err, m.JSON)
+ continue
+ }
+ var h History
+ h.RowID = m.RowID * 10
+ h.Project = m.Project
+ h.Issue = ev.Issue.Number
+ h.Time = ev.CreatedAt
+ h.Who = ev.Actor.Login
+ h.Action = ev.Event
+ expectText := true
+ switch ev.Event {
+ default:
+ log.Printf("unknown event: %s\n%s", ev.Event, m.JSON)
+ expectText = false
+ case "subscribed", "unsubscribed", "reopened", "locked", "unlocked", "head_ref_deleted", "head_ref_restored", "mentioned":
+ // ok
+ expectText = false
+ case "closed", "merged", "referenced":
+ h.Text = ev.CommitID
+ expectText = ev.Event == "merged"
+ case "assigned", "unassigned":
+ h.Text = ev.Assignee.Login
+ case "labeled", "unlabeled":
+ h.Text = ev.Label.Name
+ case "milestoned", "demilestoned":
+ h.Text = ev.Milestone.Title
+ case "renamed":
+ if ev.Rename.From != "" {
+ h.Text = ev.Rename.From + " → " + ev.Rename.To
+ }
+ }
+ if expectText && h.Text == "" {
+ log.Printf("missing text: %s\n%s", ev.Event, m.JSON)
+ }
+ if err := storage.Insert(tx, &h); err != nil {
+ log.Fatal(err)
+ }
+
+ case "/issues/comments":
+ var ev ghIssueComment
+ if err := json.Unmarshal(m.JSON, &ev); err != nil {
+ log.Printf("unmarshal: %v\n%s", err, m.JSON)
+ continue
+ }
+ i := strings.LastIndex(ev.IssueURL, "/")
+ n, err := strconv.ParseInt(ev.IssueURL[i+1:], 10, 64)
+ if err != nil {
+ log.Printf("bad issue comment:\n%s", m.JSON)
+ continue
+ }
+ var h History
+ h.RowID = m.RowID * 10
+ h.Project = m.Project
+ h.Issue = n
+ h.Time = ev.UpdatedAt
+ h.Who = ev.User.Login
+ h.Action = "comment"
+ h.Text = ev.Body
+ if err := storage.Insert(tx, &h); err != nil {
+ log.Fatal(err)
+ }
+
+ case "/issues":
+ var ev ghIssue
+ if err := json.Unmarshal(m.JSON, &ev); err != nil {
+ log.Printf("unmarshal: %v\n%s", err, m.JSON)
+ continue
+ }
+ i := strings.LastIndex(ev.URL, "/")
+ n, err := strconv.ParseInt(ev.URL[i+1:], 10, 64)
+ if err != nil {
+ log.Printf("bad issue:\n%s", m.JSON)
+ continue
+ }
+ var h History
+ h.RowID = m.RowID * 10
+ h.Project = m.Project
+ h.Issue = n
+ h.Time = ev.CreatedAt // best we can do
+ h.Who = ev.User.Login
+ h.Action = "issue"
+ h.Text = ev.Body
+ if err := storage.Insert(tx, &h); err != nil {
+ log.Fatal(err)
+ }
+
+ if ev.Assignee.Login != "" {
+ h.RowID++
+ h.Action = "assign?"
+ h.Text = ev.Assignee.Login
+ if err := storage.Insert(tx, &h); err != nil {
+ log.Fatal(err)
+ }
+ }
+ if ev.Milestone.Title != "" {
+ h.RowID++
+ h.Action = "milestone?"
+ h.Text = ev.Assignee.Login
+ if err := storage.Insert(tx, &h); err != nil {
+ log.Fatal(err)
+ }
+ }
+ if ev.State != "open" {
+ h.RowID++
+ h.Action = "close?"
+ h.Text = ""
+ if err := storage.Insert(tx, &h); err != nil {
+ log.Fatal(err)
+ }
+ }
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ log.Fatal(err)
+ }
+ }
+}