From 00d978b89963178353258acf67322d0bf77e6e8f Mon Sep 17 00:00:00 2001 From: Manfred Touron Date: Mon, 22 Oct 2018 17:57:34 +0200 Subject: [PATCH] chore: big refactor --- Makefile | 6 +- cmd_airtable.go | 629 +++++++++++++++++++++++++++++++-------------- cmd_db.go | 62 +++-- cmd_graph.go | 461 +++++++++++++++++++++++++++++++++ cmd_pull.go | 146 +++-------- cmd_run.go | 120 ++++----- cmd_web.go | 42 ++- compute.go | 160 ++++++++++++ github.go | 190 ++++++++++++++ gitlab.go | 215 ++++++++++++++++ go.mod | 2 +- go.sum | 3 + graphviz.go | 231 ----------------- issue.go | 454 ++++++-------------------------- issue_test.go | 24 +- issues.go | 24 ++ main.go | 32 ++- models.go | 172 +++++++++++++ models_airtable.go | 610 +++++++++++++++++++++++++++++++++++++++++++ repo.go | 69 ----- target.go | 156 +++++++++++ target_test.go | 57 ++++ util.go | 46 ++-- 23 files changed, 2789 insertions(+), 1122 deletions(-) create mode 100644 cmd_graph.go create mode 100644 compute.go create mode 100644 github.go create mode 100644 gitlab.go delete mode 100644 graphviz.go create mode 100644 issues.go create mode 100644 models.go create mode 100644 models_airtable.go delete mode 100644 repo.go create mode 100644 target.go create mode 100644 target_test.go diff --git a/Makefile b/Makefile index c5111d219..c7cbcb117 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ .PHONY: install install: - GO111MODULE=auto go install -v + GO111MODULE=on go install -v + +.PHONY: test +test: + go test -v ./... .PHONY: update_examples update_examples: diff --git a/cmd_airtable.go b/cmd_airtable.go index bd0f07da3..28cc0da7b 100644 --- a/cmd_airtable.go +++ b/cmd_airtable.go @@ -2,9 +2,7 @@ package main import ( "encoding/json" - "reflect" - "sort" - "time" + "fmt" "github.com/brianloveswords/airtable" "github.com/pkg/errors" @@ -15,21 +13,36 @@ import ( ) type airtableOptions struct { - AirtableTableName string `mapstructure:"airtable-table-name"` - AirtableBaseID string `mapstructure:"airtable-base-id"` - AirtableToken string `mapstructure:"airtable-token"` - Targets []string + IssuesTableName string `mapstructure:"airtable-issues-table-name"` + RepositoriesTableName string `mapstructure:"airtable-repositories-table-name"` + LabelsTableName string `mapstructure:"airtable-labels-table-name"` + MilestonesTableName string `mapstructure:"airtable-milestones-table-name"` + ProvidersTableName string `mapstructure:"airtable-providers-table-name"` + AccountsTableName string `mapstructure:"airtable-accounts-table-name"` + BaseID string `mapstructure:"airtable-base-id"` + Token string `mapstructure:"airtable-token"` + DestroyInvalidRecords bool `mapstructure:"airtable-destroy-invalid-records"` + + Targets []Target `mapstructure:"targets"` } +var globalAirtableOptions airtableOptions + func (opts airtableOptions) String() string { out, _ := json.Marshal(opts) return string(out) } func airtableSetupFlags(flags *pflag.FlagSet, opts *airtableOptions) { - flags.StringVarP(&opts.AirtableTableName, "airtable-table-name", "", "Issues and PRs", "Airtable table name") - flags.StringVarP(&opts.AirtableBaseID, "airtable-base-id", "", "", "Airtable base ID") - flags.StringVarP(&opts.AirtableToken, "airtable-token", "", "", "Airtable token") + flags.StringVarP(&opts.IssuesTableName, "airtable-issues-table-name", "", "Issues and PRs", "Airtable issues table name") + flags.StringVarP(&opts.RepositoriesTableName, "airtable-repositories-table-name", "", "Repositories", "Airtable repositories table name") + flags.StringVarP(&opts.AccountsTableName, "airtable-accounts-table-name", "", "Accounts", "Airtable accounts table name") + flags.StringVarP(&opts.LabelsTableName, "airtable-labels-table-name", "", "Labels", "Airtable labels table name") + flags.StringVarP(&opts.MilestonesTableName, "airtable-milestones-table-name", "", "Milestones", "Airtable milestones table nfame") + flags.StringVarP(&opts.ProvidersTableName, "airtable-providers-table-name", "", "Providers", "Airtable providers table name") + flags.StringVarP(&opts.BaseID, "airtable-base-id", "", "", "Airtable base ID") + flags.StringVarP(&opts.Token, "airtable-token", "", "", "Airtable token") + flags.BoolVarP(&opts.DestroyInvalidRecords, "airtable-destroy-invalid-records", "", false, "Destroy invalid records") viper.BindPFlags(flags) } @@ -42,234 +55,464 @@ func newAirtableCommand() *cobra.Command { } func newAirtableSyncCommand() *cobra.Command { - opts := &airtableOptions{} cmd := &cobra.Command{ Use: "sync", RunE: func(cmd *cobra.Command, args []string) error { - if err := viper.Unmarshal(opts); err != nil { - return err + opts := globalAirtableOptions + var err error + if opts.Targets, err = ParseTargets(args); err != nil { + return errors.Wrap(err, "invalid targets") } - opts.Targets = args - return airtableSync(opts) + return airtableSync(&opts) }, } - airtableSetupFlags(cmd.Flags(), opts) + airtableSetupFlags(cmd.Flags(), &globalAirtableOptions) return cmd } +type AirtableDB struct { + Providers ProviderRecords + Labels LabelRecords + Accounts AccountRecords + Repositories RepositoryRecords + Milestones MilestoneRecords + Issues IssueRecords +} + func airtableSync(opts *airtableOptions) error { - issues, err := loadIssues(db, nil) + if opts.BaseID == "" || opts.Token == "" { + return fmt.Errorf("missing token or baseid, check '-h'") + } + + // + // prepare + // + + // load issues + issues, err := loadIssues(nil) if err != nil { return errors.Wrap(err, "failed to load issues") } - if err := issues.prepare(true); err != nil { - return errors.Wrap(err, "failed to prepare issues") + filtered := issues.FilterByTargets(opts.Targets) + logger().Debug("fetch db entries", zap.Int("count", len(filtered))) + + // unique entries + var ( + providerMap = make(map[string]*Provider) + labelMap = make(map[string]*Label) + accountMap = make(map[string]*Account) + repositoryMap = make(map[string]*Repository) + milestoneMap = make(map[string]*Milestone) + issueMap = make(map[string]*Issue) + ) + for _, issue := range filtered { + // providers + providerMap[issue.Repository.Provider.ID] = issue.Repository.Provider + + // labels + for _, label := range issue.Labels { + labelMap[label.ID] = label + } + + // accounts + if issue.Repository.Owner != nil { + accountMap[issue.Repository.Owner.ID] = issue.Repository.Owner + } + accountMap[issue.Author.ID] = issue.Author + for _, assignee := range issue.Assignees { + accountMap[assignee.ID] = assignee + } + if issue.Milestone != nil && issue.Milestone.Creator != nil { + accountMap[issue.Milestone.Creator.ID] = issue.Milestone.Creator + } + + // repositories + repositoryMap[issue.Repository.ID] = issue.Repository + // FIXME: find external repositories based on depends-on links + + // milestones + if issue.Milestone != nil { + milestoneMap[issue.Milestone.ID] = issue.Milestone + } + + // issue + issueMap[issue.ID] = issue + // FIXME: find external issues based on depends-on links } - issues.filterByTargets(opts.Targets) - logger().Debug("fetch db entries", zap.Int("count", len(issues))) + // init client at := airtable.Client{ - APIKey: opts.AirtableToken, - BaseID: opts.AirtableBaseID, + APIKey: opts.Token, + BaseID: opts.BaseID, Limiter: airtable.RateLimiter(5), } - table := at.Table(opts.AirtableTableName) - alreadyInAirtable := map[string]bool{} - - records := []airtableRecord{} - if err := table.List(&records, &airtable.Options{}); err != nil { + // fetch remote data + cache := AirtableDB{} + table := at.Table(opts.ProvidersTableName) + if err := table.List(&cache.Providers, &airtable.Options{}); err != nil { + return err + } + table = at.Table(opts.LabelsTableName) + if err := table.List(&cache.Labels, &airtable.Options{}); err != nil { + return err + } + table = at.Table(opts.AccountsTableName) + if err := table.List(&cache.Accounts, &airtable.Options{}); err != nil { + return err + } + table = at.Table(opts.RepositoriesTableName) + if err := table.List(&cache.Repositories, &airtable.Options{}); err != nil { + return err + } + table = at.Table(opts.MilestonesTableName) + if err := table.List(&cache.Milestones, &airtable.Options{}); err != nil { + return err + } + table = at.Table(opts.IssuesTableName) + if err := table.List(&cache.Issues, &airtable.Options{}); err != nil { return err } - logger().Debug("fetched airtable records", zap.Int("count", len(records))) - // create new records - for _, record := range records { - alreadyInAirtable[record.Fields.URL] = true + unmatched := AirtableDB{ + Providers: ProviderRecords{}, + Labels: LabelRecords{}, + Accounts: AccountRecords{}, + Repositories: RepositoryRecords{}, + Milestones: MilestoneRecords{}, + Issues: IssueRecords{}, } - for _, issue := range issues { - if issue.Hidden { - continue + + // + // compute fields + // + + // providers + for _, dbEntry := range providerMap { + matched := false + dbRecord := dbEntry.ToRecord(cache) + for idx, atEntry := range cache.Providers { + if atEntry.Fields.ID == dbEntry.ID { + if atEntry.Equals(dbRecord) { + cache.Providers[idx].State = airtableStateUnchanged + } else { + cache.Providers[idx].Fields = dbRecord.Fields + cache.Providers[idx].State = airtableStateChanged + } + matched = true + break + } } - if _, found := alreadyInAirtable[issue.URL]; found { - continue + if !matched { + unmatched.Providers = append(unmatched.Providers, *dbRecord) } - logger().Debug("creating airtable record without slices", zap.String("URL", issue.URL)) - r := minimalAirtableRecord{ - Fields: minimalAirtableIssue{ - URL: issue.URL, - Errors: "initialization", - }, + } + + // labels + for _, dbEntry := range labelMap { + matched := false + dbRecord := dbEntry.ToRecord(cache) + for idx, atEntry := range cache.Labels { + if atEntry.Fields.ID == dbEntry.ID { + if atEntry.Equals(dbRecord) { + cache.Labels[idx].State = airtableStateUnchanged + } else { + cache.Labels[idx].Fields = dbRecord.Fields + cache.Labels[idx].State = airtableStateChanged + } + matched = true + break + } } - if err := table.Create(&r); err != nil { - return err + if !matched { + unmatched.Labels = append(unmatched.Labels, *dbRecord) } - records = append(records, airtableRecord{ - ID: r.ID, - Fields: airtableIssue{ - URL: issue.URL, - }, - }) - } - - // update/destroy existing ones - for _, record := range records { - if issue, found := issues[record.Fields.URL]; !found { - logger().Debug("destroying airtable record", zap.String("URL", record.Fields.URL)) - if err := table.Delete(&record); err != nil { - return errors.Wrap(err, "failed to destroy record") - } - } else { - if issue.Hidden { - continue - } + } - if issue.ToAirtableRecord().Fields.Equals(record.Fields) { - continue + // accounts + for _, dbEntry := range accountMap { + matched := false + dbRecord := dbEntry.ToRecord(cache) + for idx, atEntry := range cache.Accounts { + if atEntry.Fields.ID == dbEntry.ID { + if atEntry.Equals(dbRecord) { + cache.Accounts[idx].State = airtableStateUnchanged + } else { + cache.Accounts[idx].Fields = dbRecord.Fields + cache.Accounts[idx].State = airtableStateChanged + } + matched = true + break } + } + if !matched { + unmatched.Accounts = append(unmatched.Accounts, *dbRecord) + } + } - logger().Debug("updating airtable record", zap.String("URL", issue.URL)) - record.Fields = issue.ToAirtableRecord().Fields - if err := table.Update(&record); err != nil { - logger().Warn("failed to update record, retrying without slices", zap.String("URL", issue.URL), zap.Error(err)) - record := minimalAirtableRecord{ - ID: record.ID, - Fields: minimalAirtableIssue{ - URL: issue.URL, - }, - } - if typedErr, ok := err.(airtable.ErrClientRequest); ok { - record.Fields.Errors = typedErr.Err.Error() + // repositories + for _, dbEntry := range repositoryMap { + matched := false + dbRecord := dbEntry.ToRecord(cache) + for idx, atEntry := range cache.Repositories { + if atEntry.Fields.ID == dbEntry.ID { + if atEntry.Equals(dbRecord) { + cache.Repositories[idx].State = airtableStateUnchanged } else { - record.Fields.Errors = err.Error() + cache.Repositories[idx].Fields = dbRecord.Fields + cache.Repositories[idx].State = airtableStateChanged } - if err := table.Update(&record); err != nil { - logger().Error("failed to update record without slices", zap.String("URL", issue.URL), zap.Error(err)) + matched = true + break + } + } + if !matched { + unmatched.Repositories = append(unmatched.Repositories, *dbRecord) + } + } + + // milestones + for _, dbEntry := range milestoneMap { + matched := false + dbRecord := dbEntry.ToRecord(cache) + for idx, atEntry := range cache.Milestones { + if atEntry.Fields.ID == dbEntry.ID { + if atEntry.Equals(dbRecord) { + cache.Milestones[idx].State = airtableStateUnchanged + } else { + cache.Milestones[idx].Fields = dbRecord.Fields + cache.Milestones[idx].State = airtableStateChanged } + matched = true + break } } + if !matched { + unmatched.Milestones = append(unmatched.Milestones, *dbRecord) + } } - return nil -} + // issues + for _, dbEntry := range issueMap { + matched := false + dbRecord := dbEntry.ToRecord(cache) + for idx, atEntry := range cache.Issues { + if atEntry.Fields.ID == dbEntry.ID { + if atEntry.Equals(dbRecord) { + cache.Issues[idx].State = airtableStateUnchanged + } else { + cache.Issues[idx].Fields = dbRecord.Fields + cache.Issues[idx].State = airtableStateChanged + } + matched = true + break + } + } + if !matched { + unmatched.Issues = append(unmatched.Issues, *dbRecord) + } + } -type airtableRecord struct { - ID string `json:"id,omitempty"` - Fields airtableIssue `json:"fields,omitempty"` -} + // + // update airtable + // -type minimalAirtableRecord struct { - ID string `json:"id,omitempty"` - Fields minimalAirtableIssue `json:"fields,omitempty"` -} + // providers + table = at.Table(opts.ProvidersTableName) + for _, entry := range unmatched.Providers { + logger().Debug("create airtable entry", zap.String("type", "provider"), zap.Stringer("entry", entry)) + if err := table.Create(&entry); err != nil { + return err + } + entry.State = airtableStateNew + cache.Providers = append(cache.Providers, entry) + } + for _, entry := range cache.Providers { + var err error + switch entry.State { + case airtableStateUnknown: + err = table.Delete(&entry) + logger().Debug("delete airtable entry", zap.String("type", "provider"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateChanged: + err = table.Update(&entry) + logger().Debug("update airtable entry", zap.String("type", "provider"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateUnchanged: + logger().Debug("unchanged airtable entry", zap.String("type", "provider"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + case airtableStateNew: + logger().Debug("new airtable entry", zap.String("type", "provider"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + } + } -func (ai airtableIssue) String() string { - out, _ := json.Marshal(ai) - return string(out) -} + // labels + table = at.Table(opts.LabelsTableName) + for _, entry := range unmatched.Labels { + logger().Debug("create airtable entry", zap.String("type", "label"), zap.Stringer("entry", entry)) + if err := table.Create(&entry); err != nil { + return err + } + entry.State = airtableStateNew + cache.Labels = append(cache.Labels, entry) + } + for _, entry := range cache.Labels { + var err error + switch entry.State { + case airtableStateUnknown: + err = table.Delete(&entry) + logger().Debug("delete airtable entry", zap.String("type", "label"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateChanged: + err = table.Update(&entry) + logger().Debug("update airtable entry", zap.String("type", "label"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateUnchanged: + logger().Debug("unchanged airtable entry", zap.String("type", "label"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + case airtableStateNew: + logger().Debug("new airtable entry", zap.String("type", "label"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + } + } -func (i Issue) ToAirtableRecord() airtableRecord { - typ := "issue" - if i.IsPR { - typ = "pull-request" - } - labels := []string{} - for _, label := range i.Labels { - labels = append(labels, label.ID) - } - assignees := []string{} - for _, assignee := range i.Assignees { - assignees = append(assignees, assignee.ID) - } - - return airtableRecord{ - ID: "", - Fields: airtableIssue{ - URL: i.URL, - Created: i.CreatedAt, - Updated: i.UpdatedAt, - Completed: i.CompletedAt, - Title: i.Title, - Type: typ, - Labels: labels, - Assignees: assignees, - Provider: string(i.Provider), - RepoURL: i.RepoURL, - Body: i.Body, - State: i.State, - Locked: i.Locked, - IsOrphan: i.IsOrphan, - Author: i.AuthorID, - Comments: i.Comments, - Milestone: i.Milestone, - Upvotes: i.Upvotes, - Downvotes: i.Downvotes, - Weight: i.Weight(), - Errors: "", - }, + // accounts + table = at.Table(opts.AccountsTableName) + for _, entry := range unmatched.Accounts { + logger().Debug("create airtable entry", zap.String("type", "account"), zap.Stringer("entry", entry)) + if err := table.Create(&entry); err != nil { + return err + } + entry.State = airtableStateNew + cache.Accounts = append(cache.Accounts, entry) + } + for _, entry := range cache.Accounts { + var err error + switch entry.State { + case airtableStateUnknown: + err = table.Delete(&entry) + logger().Debug("delete airtable entry", zap.String("type", "account"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateChanged: + err = table.Update(&entry) + logger().Debug("update airtable entry", zap.String("type", "account"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateUnchanged: + logger().Debug("unchanged airtable entry", zap.String("type", "account"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + case airtableStateNew: + logger().Debug("new airtable entry", zap.String("type", "account"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + } } -} -type airtableIssue struct { - URL string - Created time.Time - Updated time.Time - Completed time.Time - Title string - Provider string - State string - Body string - RepoURL string - Type string - Locked bool - Author string - Comments int - Milestone string - Upvotes int - Downvotes int - IsOrphan bool - Labels []string - Assignees []string - Weight int - Errors string -} + // repositories + table = at.Table(opts.RepositoriesTableName) + for _, entry := range unmatched.Repositories { + logger().Debug("create airtable entry", zap.String("type", "repository"), zap.Stringer("entry", entry)) + if err := table.Create(&entry); err != nil { + return err + } + entry.State = airtableStateNew + cache.Repositories = append(cache.Repositories, entry) + } + for _, entry := range cache.Repositories { + var err error + switch entry.State { + case airtableStateUnknown: + err = table.Delete(&entry) + logger().Debug("delete airtable entry", zap.String("type", "repository"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateChanged: + err = table.Update(&entry) + logger().Debug("update airtable entry", zap.String("type", "repository"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateUnchanged: + logger().Debug("unchanged airtable entry", zap.String("type", "repository"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + case airtableStateNew: + logger().Debug("new airtable entry", zap.String("type", "repository"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + } + } -type minimalAirtableIssue struct { - URL string - Errors string -} + // milestones + table = at.Table(opts.MilestonesTableName) + for _, entry := range unmatched.Milestones { + logger().Debug("create airtable entry", zap.String("type", "milestone"), zap.Stringer("entry", entry)) + if err := table.Create(&entry); err != nil { + return err + } + entry.State = airtableStateNew + cache.Milestones = append(cache.Milestones, entry) + } + for _, entry := range cache.Milestones { + var err error + switch entry.State { + case airtableStateUnknown: + err = table.Delete(&entry) + logger().Debug("delete airtable entry", zap.String("type", "milestone"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateChanged: + err = table.Update(&entry) + logger().Debug("update airtable entry", zap.String("type", "milestone"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateUnchanged: + logger().Debug("unchanged airtable entry", zap.String("type", "milestone"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + case airtableStateNew: + logger().Debug("new airtable entry", zap.String("type", "milestone"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + } + } -func (ai airtableIssue) Equals(other airtableIssue) bool { - sameSlice := func(a, b []string) bool { - if a == nil { - a = []string{} + // issues + table = at.Table(opts.IssuesTableName) + for _, entry := range unmatched.Issues { + logger().Debug("create airtable entry", zap.String("type", "issue"), zap.Stringer("entry", entry)) + if err := table.Create(&entry); err != nil { + return err } - if b == nil { - b = []string{} + entry.State = airtableStateNew + cache.Issues = append(cache.Issues, entry) + } + for _, entry := range cache.Issues { + var err error + switch entry.State { + case airtableStateUnknown: + err = table.Delete(&entry) + logger().Debug("delete airtable entry", zap.String("type", "issue"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateChanged: + err = table.Update(&entry) + logger().Debug("update airtable entry", zap.String("type", "issue"), zap.Stringer("entry", entry), zap.Error(err)) + case airtableStateUnchanged: + logger().Debug("unchanged airtable entry", zap.String("type", "issue"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing + case airtableStateNew: + logger().Debug("new airtable entry", zap.String("type", "issue"), zap.Stringer("entry", entry), zap.Error(err)) + // do nothing } - sort.Strings(a) - sort.Strings(b) - return reflect.DeepEqual(a, b) - } - return ai.URL == other.URL && - ai.Created.Truncate(time.Millisecond).UTC() == other.Created.Truncate(time.Millisecond).UTC() && - ai.Updated.Truncate(time.Millisecond).UTC() == other.Updated.Truncate(time.Millisecond).UTC() && - ai.Completed.Truncate(time.Millisecond).UTC() == other.Completed.Truncate(time.Millisecond).UTC() && - ai.Title == other.Title && - ai.Provider == other.Provider && - ai.State == other.State && - ai.Body == other.Body && - ai.RepoURL == other.RepoURL && - ai.Type == other.Type && - ai.Locked == other.Locked && - ai.Author == other.Author && - ai.Comments == other.Comments && - ai.Milestone == other.Milestone && - ai.Weight == other.Weight && - ai.IsOrphan == other.IsOrphan && - ai.Upvotes == other.Upvotes && - ai.Downvotes == other.Downvotes && - sameSlice(ai.Labels, other.Labels) && - sameSlice(ai.Assignees, other.Assignees) && - ai.Errors == other.Errors + } + + // + // debug + // + fmt.Println("------- providers") + for _, entry := range cache.Providers { + fmt.Println(entry.ID, airtableStateString[entry.State], entry.Fields.ID) + } + fmt.Println("------- labels") + for _, entry := range cache.Labels { + fmt.Println(entry.ID, airtableStateString[entry.State], entry.Fields.ID) + } + fmt.Println("------- accounts") + for _, entry := range cache.Accounts { + fmt.Println(entry.ID, airtableStateString[entry.State], entry.Fields.ID) + } + fmt.Println("------- repositories") + for _, entry := range cache.Repositories { + fmt.Println(entry.ID, airtableStateString[entry.State], entry.Fields.ID) + } + fmt.Println("------- milestones") + for _, entry := range cache.Milestones { + fmt.Println(entry.ID, airtableStateString[entry.State], entry.Fields.ID) + } + fmt.Println("------- issues") + for _, entry := range cache.Issues { + fmt.Println(entry.ID, airtableStateString[entry.State], entry.Fields.ID) + } + fmt.Println("-------") + + return nil } diff --git a/cmd_db.go b/cmd_db.go index 0b72c76ff..43943281e 100644 --- a/cmd_db.go +++ b/cmd_db.go @@ -4,15 +4,14 @@ import ( "encoding/json" "fmt" - "github.com/jinzhu/gorm" - "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" ) -type dbOptions struct { -} +type dbOptions struct{} + +var globalDBOptions dbOptions func (opts dbOptions) String() string { out, _ := json.Marshal(opts) @@ -32,26 +31,28 @@ func newDBCommand() *cobra.Command { } func newDBDumpCommand() *cobra.Command { - opts := &dbOptions{} cmd := &cobra.Command{ Use: "dump", RunE: func(cmd *cobra.Command, args []string) error { - if err := viper.Unmarshal(opts); err != nil { - return err - } - return dbDump(opts) + opts := globalDBOptions + return dbDump(&opts) }, } - dbSetupFlags(cmd.Flags(), opts) + dbSetupFlags(cmd.Flags(), &globalDBOptions) return cmd } func dbDump(opts *dbOptions) error { - issues, err := loadIssues(db, nil) - if err != nil { - return errors.Wrap(err, "failed to load issues") + issues := []*Issue{} + if err := db.Find(&issues).Error; err != nil { + return err } - out, err := json.MarshalIndent(issues.ToSlice(), "", " ") + + for _, issue := range issues { + issue.PostLoad() + } + + out, err := json.MarshalIndent(issues, "", " ") if err != nil { return err } @@ -59,26 +60,15 @@ func dbDump(opts *dbOptions) error { return nil } -func canonicalTargets(input []string) []string { - output := []string{} - base := Issue{RepoURL: "https://github.com/moul/depviz", URL: "https://github.com/moul/depviz/issues/1"} - for _, target := range input { - output = append(output, base.GetRelativeIssueURL(target)) - } - return output -} - -func loadIssues(db *gorm.DB, targets []string) (Issues, error) { - query := db.Model(Issue{}) +func loadIssues(targets []string) (Issues, error) { + query := db.Model(Issue{}).Order("created_at") if len(targets) > 0 { - query = query.Where("repo_url IN (?)", canonicalTargets(targets)) + return nil, fmt.Errorf("not implemented") + // query = query.Where("repo_url IN (?)", canonicalTargets(targets)) + // OR WHERE parents IN .... + // etc } - /*var count int - if err := query.Count(&count).Error; err != nil { - return nil, err - }*/ - perPage := 100 var issues []*Issue for page := 0; ; page++ { @@ -91,6 +81,12 @@ func loadIssues(db *gorm.DB, targets []string) (Issues, error) { break } } - slice := IssueSlice(issues) - return slice.ToMap(), nil + + for _, issue := range issues { + issue.PostLoad() + } + + return Issues(issues), nil } + +// FIXME: try to use gorm hooks to auto preload/postload items diff --git a/cmd_graph.go b/cmd_graph.go new file mode 100644 index 000000000..0165cc6b2 --- /dev/null +++ b/cmd_graph.go @@ -0,0 +1,461 @@ +package main + +import ( + "encoding/json" + "fmt" + "html" + "io" + "math" + "net/url" + "os" + "sort" + "strings" + + "github.com/awalterschulze/gographviz" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +type graphOptions struct { + Output string `mapstructure:"output"` + DebugGraph bool `mapstructure:"debug-graph"` + NoCompress bool `mapstructure:"no-compress"` + DarkTheme bool `mapstructure:"dark-theme"` + ShowClosed bool `mapstructure:"show-closed"` + ShowOrphans bool `mapstructure:"show-orphans"` + ShowPRs bool `mapstructure:"show-prs"` + Preview bool `mapstructure:"preview"` + Format string `mapstructure:"format"` + Targets Targets `mapstructure:"targets"` + // FocusMode + // NoExternal +} + +var globalGraphOptions graphOptions + +func (opts graphOptions) String() string { + out, _ := json.Marshal(opts) + return string(out) +} + +func graphSetupFlags(flags *pflag.FlagSet, opts *graphOptions) { + flags.BoolVarP(&opts.ShowClosed, "show-closed", "", false, "show closed issues") + flags.BoolVarP(&opts.DebugGraph, "debug-graph", "", false, "debug graph") + flags.BoolVarP(&opts.ShowOrphans, "show-orphans", "", false, "show issues not linked to an epic") + flags.BoolVarP(&opts.NoCompress, "no-compress", "", false, "do not compress graph (no overlap)") + flags.BoolVarP(&opts.DarkTheme, "dark-theme", "", false, "dark theme") + flags.BoolVarP(&opts.ShowPRs, "show-prs", "", false, "show PRs") + flags.StringVarP(&opts.Output, "output", "o", "-", "output file ('-' for stdout, dot)") + flags.StringVarP(&opts.Format, "format", "f", "", "output file format (if empty, will determine thanks to output extension)") + //flags.BoolVarP(&opts.Preview, "preview", "p", false, "preview result") + viper.BindPFlags(flags) +} + +func newGraphCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "graph", + RunE: func(cmd *cobra.Command, args []string) error { + opts := globalGraphOptions + var err error + if opts.Targets, err = ParseTargets(args); err != nil { + return errors.Wrap(err, "invalid targets") + } + return graph(&opts) + }, + } + graphSetupFlags(cmd.Flags(), &globalGraphOptions) + return cmd +} + +func graph(opts *graphOptions) error { + logger().Debug("graph", zap.Stringer("opts", *opts)) + issues, err := loadIssues(nil) + if err != nil { + return errors.Wrap(err, "failed to load issues") + } + filtered := issues.FilterByTargets(opts.Targets) + + out, err := graphviz(filtered, opts) + if err != nil { + return errors.Wrap(err, "failed to render graph") + } + + switch opts.Format { + case "png", "svg": + return fmt.Errorf("only supporting .dot format for now") + + //case "dot": + default: + } + + var dest io.WriteCloser + switch opts.Output { + case "-", "": + dest = os.Stdout + default: + var err error + dest, err = os.Create(opts.Output) + if err != nil { + return err + } + defer dest.Close() + } + fmt.Fprintln(dest, out) + return nil +} + +func isIssueHidden(issue *Issue, opts *graphOptions) bool { + if issue.IsHidden { + return true + } + if !opts.ShowClosed && issue.IsClosed() { + return true + } + if !opts.ShowOrphans && issue.IsOrphan { + return true + } + if !opts.ShowPRs && issue.IsPR { + return true + } + return false +} + +func graphviz(issues Issues, opts *graphOptions) (string, error) { + for _, issue := range issues { + if isIssueHidden(issue, opts) { + continue + } + } + + var ( + stats = map[string]int{ + "nodes": 0, + "edges": 0, + "hidden": 0, + "subgraphs": 0, + } + invisStyle = map[string]string{"style": "invis", "label": escape("")} + weightMap = map[int]bool{} + weights = []int{} + existingNodes = map[string]bool{} + ) + if opts.DebugGraph { + invisStyle = map[string]string{} + } + for _, issue := range issues { + if isIssueHidden(issue, opts) { + stats["hidden"]++ + continue + } + weightMap[issue.Weight] = true + } + for weight := range weightMap { + weights = append(weights, weight) + } + sort.Ints(weights) + + // initialize graph + g := gographviz.NewGraph() + panicIfErr(g.SetName("G")) + attrs := map[string]string{} + attrs["truecolor"] = "true" + attrs["rankdir"] = "RL" + attrs["constraint"] = "true" + attrs["compound"] = "true" + if !opts.NoCompress { + attrs["center"] = "true" + attrs["ranksep"] = "0.3" + attrs["nodesep"] = "0.1" + attrs["margin"] = "0.2" + attrs["sep"] = "-0.7" + attrs["constraint"] = "false" + attrs["splines"] = "true" + attrs["overlap"] = "compress" + } + if opts.DarkTheme { + attrs["bgcolor"] = "black" + } + + for k, v := range attrs { + panicIfErr(g.AddAttr("G", k, v)) + } + panicIfErr(g.SetDir(true)) + + // issue nodes + issueNumbers := []string{} + for _, issue := range issues { + issueNumbers = append(issueNumbers, issue.URL) + } + sort.Strings(issueNumbers) + + orphansWithoutLinks := 0 + for _, id := range issueNumbers { + issue := issues.Get(id) + if isIssueHidden(issue, opts) { + continue + } + if len(issue.Parents) == 0 && len(issue.Children) == 0 { + orphansWithoutLinks++ + } + } + orphansCols := int(math.Ceil(math.Sqrt(float64(orphansWithoutLinks)) / 2)) + colIndex := 0 + hasOrphansWithLinks := false + for _, id := range issueNumbers { + issue := issues.Get(id) + if isIssueHidden(issue, opts) { + continue + } + parent := fmt.Sprintf("cluster_weight_%d", issue.Weight) + if issue.IsOrphan || !issue.HasEpic { + if len(issue.Children) > 0 || len(issue.Parents) > 0 { + parent = "cluster_orphans_with_links" + hasOrphansWithLinks = true + } else { + parent = fmt.Sprintf("cluster_orphans_without_links_%d", colIndex%orphansCols) + colIndex++ + } + } + + existingNodes[issue.URL] = true + panicIfErr(issue.AddNodeToGraph(g, parent)) + stats["nodes"]++ + } + + // issue relationships + for _, issue := range issues { + panicIfErr(issue.AddEdgesToGraph(g, opts, existingNodes)) + stats["edges"]++ + } + + // orphans cluster and placeholder + if orphansWithoutLinks > 0 { + panicIfErr(g.AddSubGraph( + "G", + "cluster_orphans_without_links", + map[string]string{"label": escape("orphans without links"), "style": "dashed"}, + )) + stats["subgraphs"]++ + + panicIfErr(g.AddSubGraph( + "cluster_orphans_without_links", + "cluster_orphans_without_links_0", + invisStyle, + )) + stats["subgraphs"]++ + for i := 0; i < orphansCols; i++ { + panicIfErr(g.AddNode( + fmt.Sprintf("cluster_orphans_without_links_%d", i), + fmt.Sprintf("placeholder_orphans_without_links_%d", i), + invisStyle, + )) + stats["nodes"]++ + } + + panicIfErr(g.AddEdge( + fmt.Sprintf("placeholder_%d", weights[len(weights)-1]), + "placeholder_orphans_without_links_0", + true, + invisStyle, + )) + stats["edges"]++ + + for i := 1; i < orphansCols; i++ { + panicIfErr(g.AddSubGraph( + "cluster_orphans_without_links", + fmt.Sprintf("cluster_orphans_without_links_%d", i), + invisStyle, + )) + stats["subgraphs"]++ + panicIfErr(g.AddEdge( + fmt.Sprintf("placeholder_orphans_without_links_%d", i-1), + fmt.Sprintf("placeholder_orphans_without_links_%d", i), + true, + invisStyle, + )) + stats["edges"]++ + } + } + if hasOrphansWithLinks { + attrs := map[string]string{} + attrs["label"] = escape("orphans with links") + attrs["style"] = "dashed" + panicIfErr(g.AddSubGraph("G", "cluster_orphans_with_links", attrs)) + stats["subgraphs"]++ + + panicIfErr(g.AddNode("cluster_orphans_with_links", "placeholder_orphans_with_links", invisStyle)) + stats["nodes"]++ + + panicIfErr(g.AddEdge( + "placeholder_orphans_with_links", + fmt.Sprintf("placeholder_%d", weights[0]), + true, + invisStyle, + )) + stats["edges"]++ + } + + // set weights clusters and placeholders + for _, weight := range weights { + clusterName := fmt.Sprintf("cluster_weight_%d", weight) + attrs := invisStyle + attrs["rank"] = "same" + panicIfErr(g.AddSubGraph("G", clusterName, attrs)) + stats["subgraphs"]++ + + attrs = invisStyle + attrs["shape"] = "none" + attrs["label"] = fmt.Sprintf(`"weight=%d"`, weight) + panicIfErr(g.AddNode( + clusterName, + fmt.Sprintf("placeholder_%d", weight), + attrs, + )) + stats["nodes"]++ + } + for i := 0; i < len(weights)-1; i++ { + panicIfErr(g.AddEdge( + fmt.Sprintf("placeholder_%d", weights[i]), + fmt.Sprintf("placeholder_%d", weights[i+1]), + true, + invisStyle, + )) + stats["edges"]++ + } + + logger().Debug("graph stats", zap.Any("stats", stats)) + return g.String(), nil +} + +func (i Issue) AddNodeToGraph(g *gographviz.Graph, parent string) error { + attrs := map[string]string{} + attrs["label"] = i.GraphNodeTitle() + //attrs["xlabel"] = "" + attrs["shape"] = "record" + attrs["style"] = `"rounded,filled"` + attrs["color"] = "lightblue" + attrs["href"] = escape(i.URL) + + if i.IsEpic { + attrs["shape"] = "oval" + } + + switch { + + case i.IsClosed(): + attrs["color"] = `"#cccccc33"` + + case i.IsEpic: + attrs["color"] = "orange" + attrs["style"] = `"rounded,filled,bold"` + + case i.IsReady(): + attrs["color"] = "pink" + + case i.IsOrphan || !i.HasEpic: + attrs["color"] = "gray" + } + + //logger().Debug("add node to graph", zap.String("url", i.URL)) + return g.AddNode( + parent, + escape(i.URL), + attrs, + ) +} + +func (i Issue) AddEdgesToGraph(g *gographviz.Graph, opts *graphOptions, existingNodes map[string]bool) error { + if isIssueHidden(&i, opts) { + return nil + } + for _, dependency := range i.Parents { + if isIssueHidden(dependency, opts) { + continue + } + if _, found := existingNodes[dependency.URL]; !found { + continue + } + attrs := map[string]string{} + attrs["color"] = "lightblue" + //attrs["label"] = "depends on" + //attrs["style"] = "dotted" + attrs["dir"] = "none" + if i.IsClosed() || dependency.IsClosed() { + attrs["color"] = "grey" + attrs["style"] = "dashed" + } else if dependency.IsReady() { + attrs["color"] = "pink" + } + if i.IsEpic { + attrs["color"] = "orange" + attrs["style"] = "dashed" + } + //log.Print("edge", escape(i.URL), "->", escape(dependency.URL)) + //logger().Debug("add edge to graph", zap.String("url", i.URL), zap.String("dep", dependency.URL)) + if err := g.AddEdge( + escape(i.URL), + escape(dependency.URL), + true, + attrs, + ); err != nil { + return err + } + } + return nil +} + +func (i Issue) GraphNodeName() string { + return fmt.Sprintf(`%s#%s`, i.Path()[1:], i.Number()) +} + +func (i Issue) Number() string { + u, err := url.Parse(i.URL) + if err != nil { + return "" + } + parts := strings.Split(u.Path, "/") + return parts[len(parts)-1] +} + +func (i Issue) Path() string { + u, err := url.Parse(i.URL) + if err != nil { + return "" + } + parts := strings.Split(u.Path, "/") + return strings.Join(parts[:len(parts)-2], "/") +} + +func (i Issue) GraphNodeTitle() string { + title := fmt.Sprintf("%s: %s", i.GraphNodeName(), i.Title) + title = strings.Replace(title, "|", "-", -1) + title = strings.Replace(html.EscapeString(wrap(title, 20)), "\n", "
", -1) + labels := []string{} + for _, label := range i.Labels { + switch label.ID { + case "t/step", "t/epic", "epic": + continue + } + labels = append(labels, fmt.Sprintf(`%s`, label.Color, label.Name)) + } + labelsText := "" + if len(labels) > 0 { + labelsText = "" + strings.Join(labels, "") + "
" + } + assigneeText := "" + if len(i.Assignees) > 0 { + assignees := []string{} + for _, assignee := range i.Assignees { + assignees = append(assignees, assignee.ID) + } + assigneeText = fmt.Sprintf(`@%s`, strings.Join(assignees, ", @")) + } + errorsText := "" + if len(i.Errors) > 0 { + errorsText = fmt.Sprintf(`ERR: %s`, strings.Join(i.Errors, ";
ERR: ")) + } + return fmt.Sprintf(`<%s%s%s
%s
>`, title, labelsText, assigneeText, errorsText) +} diff --git a/cmd_pull.go b/cmd_pull.go index a523e24a7..f3c1ad1da 100644 --- a/cmd_pull.go +++ b/cmd_pull.go @@ -1,34 +1,28 @@ package main import ( - "context" "encoding/json" - "fmt" - "log" + "os" "sync" - "github.com/google/go-github/github" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" - gitlab "github.com/xanzy/go-gitlab" "go.uber.org/zap" - "golang.org/x/oauth2" ) type pullOptions struct { - // db - DBOpts dbOptions - // pull - Repos []string GithubToken string `mapstructure:"github-token"` GitlabToken string `mapstructure:"gitlab-token"` // includeExternalDeps bool - Targets []string + Targets Targets `mapstructure:"targets"` } +var globalPullOptions pullOptions + func (opts pullOptions) String() string { out, _ := json.Marshal(opts) return string(out) @@ -41,23 +35,35 @@ func pullSetupFlags(flags *pflag.FlagSet, opts *pullOptions) { } func newPullCommand() *cobra.Command { - opts := &pullOptions{} cmd := &cobra.Command{ Use: "pull", RunE: func(cmd *cobra.Command, args []string) error { - if err := viper.Unmarshal(opts); err != nil { - return err + opts := globalPullOptions + var err error + if opts.Targets, err = ParseTargets(args); err != nil { + return errors.Wrap(err, "invalid targets") } - opts.Targets = args - return pull(opts) + return pullAndCompute(&opts) }, } - pullSetupFlags(cmd.Flags(), opts) - dbSetupFlags(cmd.Flags(), &opts.DBOpts) + pullSetupFlags(cmd.Flags(), &globalPullOptions) return cmd } +func pullAndCompute(opts *pullOptions) error { + if os.Getenv("DEPVIZ_NOPULL") != "1" { + if err := pull(opts); err != nil { + return errors.Wrap(err, "failed to pull") + } + } + if err := compute(opts); err != nil { + return errors.Wrap(err, "failed to compute") + } + return nil +} + func pull(opts *pullOptions) error { + // FIXME: handle the special '@me' target logger().Debug("pull", zap.Stringer("opts", *opts)) var ( @@ -66,101 +72,16 @@ func pull(opts *pullOptions) error { out = make(chan []*Issue, 100) ) - repos := getReposFromTargets(opts.Targets) - - wg.Add(len(repos)) - for _, repoURL := range repos { - repo := NewRepo(repoURL) - switch repo.Provider() { - case GitHubProvider: - ctx := context.Background() - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: opts.GithubToken}) - tc := oauth2.NewClient(ctx, ts) - client := github.NewClient(tc) - - go func(repo Repo) { - - total := 0 - defer wg.Done() - opts := &github.IssueListByRepoOptions{State: "all"} - - var lastEntry Issue - if err := db.Where("repo_url = ?", repo.Canonical()).Order("updated_at desc").First(&lastEntry).Error; err == nil { - opts.Since = lastEntry.UpdatedAt - } - - for { - issues, resp, err := client.Issues.ListByRepo(ctx, repo.Namespace(), repo.Project(), opts) - if err != nil { - log.Fatal(err) - return - } - total += len(issues) - logger().Debug("paginate", - zap.String("provider", "github"), - zap.String("repo", repo.Canonical()), - zap.Int("new-issues", len(issues)), - zap.Int("total-issues", total), - ) - normalizedIssues := []*Issue{} - for _, issue := range issues { - normalizedIssues = append(normalizedIssues, FromGitHubIssue(issue)) - } - out <- normalizedIssues - if resp.NextPage == 0 { - break - } - opts.Page = resp.NextPage - } - if rateLimits, _, err := client.RateLimits(ctx); err == nil { - logger().Debug("github API rate limiting", zap.Stringer("limit", rateLimits.GetCore())) - } - }(repo) - case GitLabProvider: - go func(repo Repo) { - client := gitlab.NewClient(nil, opts.GitlabToken) - client.SetBaseURL(fmt.Sprintf("%s/api/v4", repo.SiteURL())) - - //projectID := url.QueryEscape(repo.RepoPath()) - projectID := repo.RepoPath() - total := 0 - defer wg.Done() - opts := &gitlab.ListProjectIssuesOptions{ - ListOptions: gitlab.ListOptions{ - PerPage: 30, - Page: 1, - }, - } - - var lastEntry Issue - if err := db.Where("repo_url = ?", repo.Canonical()).Order("updated_at desc").First(&lastEntry).Error; err == nil { - opts.UpdatedAfter = &lastEntry.UpdatedAt - } + targets := opts.Targets.UniqueProjects() - for { - issues, resp, err := client.Issues.ListProjectIssues(projectID, opts) - if err != nil { - logger().Error("failed to pull issues", zap.Error(err)) - return - } - total += len(issues) - logger().Debug("paginate", - zap.String("provider", "gitlab"), - zap.String("repo", repo.Canonical()), - zap.Int("new-issues", len(issues)), - zap.Int("total-issues", total), - ) - normalizedIssues := []*Issue{} - for _, issue := range issues { - normalizedIssues = append(normalizedIssues, FromGitLabIssue(issue)) - } - out <- normalizedIssues - if resp.NextPage == 0 { - break - } - opts.ListOptions.Page = resp.NextPage - } - }(repo) + // parallel fetches + wg.Add(len(targets)) + for _, target := range targets { + switch target.Driver() { + case GithubDriver: + go githubPull(target, &wg, opts, out) + case GitlabDriver: + go gitlabPull(target, &wg, opts, out) default: panic("should not happen") } @@ -171,6 +92,7 @@ func pull(opts *pullOptions) error { allIssues = append(allIssues, issues...) } + // save for _, issue := range allIssues { if err := db.Save(issue).Error; err != nil { return err diff --git a/cmd_run.go b/cmd_run.go index 5e74d6bd7..170391db6 100644 --- a/cmd_run.go +++ b/cmd_run.go @@ -2,100 +2,74 @@ package main import ( "encoding/json" - "fmt" - "io" - "os" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" - "go.uber.org/zap" ) type runOptions struct { - // pull - PullOpts pullOptions - NoPull bool - ReposToFetch []string - - // db - DBOpts dbOptions - GraphOpts graphOptions - - // run - AdditionalPulls []string - Destination string - - //Preview bool + GraphOptions graphOptions `mapstructure:"graph"` + PullOptions pullOptions `mapstructure:"pull"` + AdditionalPulls []string `mapstructure:"additional-pulls"` + NoPull bool `mapstructure:"no-pull"` } +var globalRunOptions runOptions + func (opts runOptions) String() string { out, _ := json.Marshal(opts) return string(out) } func runSetupFlags(flags *pflag.FlagSet, opts *runOptions) { - flags.BoolVarP(&opts.NoPull, "no-pull", "f", false, "do not pull new issues before runing") - flags.StringVarP(&opts.Destination, "destination", "", "-", "destination ('-' for stdout)") - flags.StringSliceVarP(&opts.AdditionalPulls, "additional-pull", "", []string{}, "additional pull that won't necessarily be displayed on the graph") - //flags.BoolVarP(&opts.Preview, "preview", "p", false, "preview result") + flags.BoolVarP(&opts.NoPull, "no-pull", "", false, "do not pull new issues before running") + flags.StringSliceVarP(&opts.AdditionalPulls, "additional-pulls", "", []string{}, "additional pull that won't necessarily be displayed on the graph") viper.BindPFlags(flags) } func newRunCommand() *cobra.Command { - opts := &runOptions{} cmd := &cobra.Command{ Use: "run", RunE: func(cmd *cobra.Command, args []string) error { - if err := viper.Unmarshal(opts); err != nil { - return err - } - if err := viper.Unmarshal(&opts.PullOpts); err != nil { - return err - } - if err := viper.Unmarshal(&opts.DBOpts); err != nil { - return err + opts := globalRunOptions + opts.GraphOptions = globalGraphOptions + opts.PullOptions = globalPullOptions + + targets, err := ParseTargets(args) + if err != nil { + return errors.Wrap(err, "invalid targets") } - if err := viper.Unmarshal(&opts.GraphOpts); err != nil { - return err + additionalPulls, err := ParseTargets(opts.AdditionalPulls) + if err != nil { + return errors.Wrap(err, "invalid targets") } - opts.PullOpts.DBOpts = opts.DBOpts - opts.PullOpts.Targets = append(args, opts.AdditionalPulls...) - opts.GraphOpts.Targets = args - return run(opts) + opts.PullOptions.Targets = append(targets, additionalPulls...) + opts.GraphOptions.Targets = targets + return run(&opts) }, } - runSetupFlags(cmd.Flags(), opts) - pullSetupFlags(cmd.Flags(), &opts.PullOpts) - dbSetupFlags(cmd.Flags(), &opts.DBOpts) - graphSetupFlags(cmd.Flags(), &opts.GraphOpts) + runSetupFlags(cmd.Flags(), &globalRunOptions) + graphSetupFlags(cmd.Flags(), &globalGraphOptions) + pullSetupFlags(cmd.Flags(), &globalPullOptions) return cmd } -func graphviz(opts *graphOptions) (string, error) { - if opts.Targets == nil || len(opts.Targets) < 1 || opts.Targets[0] == "" { - return "", fmt.Errorf("you need to specify at least one target") - } - issues, err := loadIssues(db, nil) - if err != nil { - return "", errors.Wrap(err, "failed to load issues") +func run(opts *runOptions) error { + if !opts.NoPull { + if err := pullAndCompute(&opts.PullOptions); err != nil { + return errors.Wrap(err, "failed to pull") + } } - - if err := issues.prepare(false); err != nil { - return "", errors.Wrap(err, "failed to prepare issues") + if err := graph(&opts.GraphOptions); err != nil { + return errors.Wrap(err, "failed to graph") } + return nil +} - if !opts.ShowClosed { - issues.HideClosed() - } - issues.filterByTargets(opts.Targets) - if opts.ShowOrphans { - logger().Warn("--show-orphans is deprecated and will be removed") - } +/* - return graphvizRender(issues, opts) -} func run(opts *runOptions) error { logger().Debug("run", zap.Stringer("opts", *opts)) @@ -126,3 +100,29 @@ func run(opts *runOptions) error { return nil } + +func graphviz(opts *graphOptions) (string, error) { + if opts.Targets == nil || len(opts.Targets) < 1 || opts.Targets[0] == "" { + return "", fmt.Errorf("you need to specify at least one target") + } + issues, err := loadIssues(db, nil) + if err != nil { + return "", errors.Wrap(err, "failed to load issues") + } + + if err := issues.prepare(false); err != nil { + return "", errors.Wrap(err, "failed to prepare issues") + } + + if !opts.ShowClosed { + issues.HideClosed() + } + issues.filterByTargets(opts.Targets) + if opts.ShowOrphans { + logger().Warn("--show-orphans is deprecated and will be removed") + } + + return graphvizRender(issues, opts) +} + +*/ diff --git a/cmd_web.go b/cmd_web.go index cf01fedbb..9d92c30ba 100644 --- a/cmd_web.go +++ b/cmd_web.go @@ -25,11 +25,10 @@ type webOptions struct { // web specific Bind string ShowRoutes bool - - // db - DBOpts dbOptions } +var globalWebOptions webOptions + func (opts webOptions) String() string { out, _ := json.Marshal(opts) return string(out) @@ -42,21 +41,14 @@ func webSetupFlags(flags *pflag.FlagSet, opts *webOptions) { } func newWebCommand() *cobra.Command { - opts := &webOptions{} cmd := &cobra.Command{ Use: "web", RunE: func(cmd *cobra.Command, args []string) error { - if err := viper.Unmarshal(opts); err != nil { - return err - } - if err := viper.Unmarshal(&opts.DBOpts); err != nil { - return err - } - return web(opts) + opts := globalWebOptions + return web(&opts) }, } - webSetupFlags(cmd.Flags(), opts) - dbSetupFlags(cmd.Flags(), &opts.DBOpts) + webSetupFlags(cmd.Flags(), &globalWebOptions) return cmd } @@ -65,23 +57,18 @@ func (i *Issue) Render(w http.ResponseWriter, r *http.Request) error { } func webListIssues(w http.ResponseWriter, r *http.Request) { - issues, err := loadIssues(db, nil) + issues, err := loadIssues(nil) if err != nil { render.Render(w, r, ErrRender(err)) return } - issues.prepare(true) - - targets := strings.Split(r.URL.Query().Get("targets"), ",") - issues.filterByTargets(targets) - list := []render.Renderer{} for _, issue := range issues { - if issue.Hidden { + if issue.IsHidden { continue } - list = append(list, issue.WithJSONFields()) + list = append(list, issue) } if err := render.RenderList(w, r, list); err != nil { @@ -91,11 +78,20 @@ func webListIssues(w http.ResponseWriter, r *http.Request) { } func webGraphviz(r *http.Request) (string, error) { + targets, err := ParseTargets(strings.Split(r.URL.Query().Get("targets"), ",")) + if err != nil { + return "", err + } opts := &graphOptions{ - Targets: strings.Split(r.URL.Query().Get("targets"), ","), + Targets: targets, ShowClosed: r.URL.Query().Get("show-closed") == "1", } - return graphviz(opts) + issues, err := loadIssues(nil) + if err != nil { + return "", err + } + filtered := issues.FilterByTargets(targets) + return graphviz(filtered, opts) } func webDotIssues(w http.ResponseWriter, r *http.Request) { diff --git a/compute.go b/compute.go new file mode 100644 index 000000000..57f3b6a96 --- /dev/null +++ b/compute.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "regexp" + "sort" + "strconv" + + "go.uber.org/zap" +) + +var ( + childrenRegex, _ = regexp.Compile(`(?i)(require|requires|blocked by|block by|depend on|depends on|parent of) ([a-z0-9:/_.-]+issues/[0-9]+|[a-z0-9:/_.-]+#[0-9]+|[a-z0-9/_-]*#[0-9]+)`) + parentsRegex, _ = regexp.Compile(`(?i)(blocks|block|address|addresses|part of|child of|fix|fixes) ([a-z0-9:/_.-]+issues/[0-9]+|[a-z0-9:/_.-]+#[0-9]+|[a-z0-9/_-]*#[0-9]+)`) + isDuplicateRegex, _ = regexp.Compile(`(?i)(duplicates|duplicate|dup of|dup|duplicate of) ([a-z0-9:/_.-]+issues/[0-9]+|[a-z0-9:/_.-]+#[0-9]+|[a-z0-9/_-]*#[0-9]+)`) + //weightMultiplierRegex, _ = regexp.Compile(`(?i)(depviz.weight_multiplier[:= ]+)([0-9]+)`) + weightRegex, _ = regexp.Compile(`(?i)(depviz.base_weight|depviz.weight)[:= ]+([0-9]+)`) + hideRegex, _ = regexp.Compile(`(?i)(depviz.hide)`) // FIXME: use label +) + +func compute(opts *pullOptions) error { + logger().Debug("compute", zap.Stringer("opts", *opts)) + issues, err := loadIssues(nil) + if err != nil { + return err + } + + for _, issue := range issues { + // reset default values + issue.Errors = []string{} + issue.Parents = []*Issue{} + issue.Children = []*Issue{} + issue.Duplicates = []*Issue{} + issue.Weight = 0 + issue.IsHidden = false + issue.IsEpic = false + issue.HasEpic = false + issue.IsOrphan = true + } + + for _, issue := range issues { + if issue.Body == "" { + continue + } + + // is epic + for _, label := range issue.Labels { + // FIXME: get epic labels dynamically based on a configuration filein the repo + if label.Name == "epic" || label.Name == "t/epic" { + issue.IsEpic = true + } + } + + // hidden + if match := hideRegex.FindStringSubmatch(issue.Body); match != nil { + issue.IsHidden = true + continue + } + + // duplicates + if match := isDuplicateRegex.FindStringSubmatch(issue.Body); match != nil { + canonical := issue.GetRelativeURL(match[len(match)-1]) + rel := issues.Get(canonical) + if rel == nil { + issue.Errors = append(issue.Errors, fmt.Errorf("duplicate %q not found", canonical).Error()) + continue + } + issue.Duplicates = append(issue.Duplicates, rel) + issue.IsHidden = true + continue + } + + // weight + if match := weightRegex.FindStringSubmatch(issue.Body); match != nil { + issue.Weight, _ = strconv.Atoi(match[len(match)-1]) + } + + // children + for _, match := range childrenRegex.FindAllStringSubmatch(issue.Body, -1) { + canonical := issue.GetRelativeURL(match[len(match)-1]) + child := issues.Get(canonical) + if child == nil { + issue.Errors = append(issue.Errors, fmt.Errorf("children %q not found", canonical).Error()) + continue + } + issue.Children = append(issue.Children, child) + issue.IsOrphan = false + child.Parents = append(child.Parents, issue) + child.IsOrphan = false + } + + // parents + for _, match := range parentsRegex.FindAllStringSubmatch(issue.Body, -1) { + canonical := issue.GetRelativeURL(match[len(match)-1]) + parent := issues.Get(canonical) + if parent == nil { + issue.Errors = append(issue.Errors, fmt.Errorf("parent %q not found", canonical).Error()) + continue + } + issue.Parents = append(issue.Parents, parent) + issue.IsOrphan = false + parent.Children = append(parent.Children, issue) + parent.IsOrphan = false + } + } + + for _, issue := range issues { + if issue.IsEpic { + issue.HasEpic = true + continue + } + // has epic + issue.HasEpic, err = issue.computeHasEpic(0) + if err != nil { + issue.Errors = append(issue.Errors, err.Error()) + } + } + + for _, issue := range issues { + issue.PostLoad() + + issue.ParentIDs = uniqueStrings(issue.ParentIDs) + sort.Strings(issue.ParentIDs) + issue.ChildIDs = uniqueStrings(issue.ChildIDs) + sort.Strings(issue.ChildIDs) + issue.DuplicateIDs = uniqueStrings(issue.DuplicateIDs) + sort.Strings(issue.DuplicateIDs) + } + + for _, issue := range issues { + // TODO: add a "if changed" to preserve some CPU and time + if err := db.Set("gorm:association_autoupdate", false).Save(issue).Error; err != nil { + return err + } + } + + return nil +} + +func (i Issue) computeHasEpic(depth int) (bool, error) { + if depth > 100 { + return false, fmt.Errorf("very high blocking depth (>100), do not continue. (issue=%s)", i.URL) + } + if i.IsHidden { + return false, nil + } + for _, parent := range i.Parents { + if parent.IsEpic { + return true, nil + } + parentHasEpic, err := parent.computeHasEpic(depth + 1) + if err != nil { + return false, nil + } + if parentHasEpic { + return true, nil + } + } + return false, nil +} diff --git a/github.go b/github.go new file mode 100644 index 000000000..5252b1d8c --- /dev/null +++ b/github.go @@ -0,0 +1,190 @@ +package main + +import ( + "context" + "log" + "strings" + "sync" + + "github.com/google/go-github/github" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +func githubPull(target Target, wg *sync.WaitGroup, opts *pullOptions, out chan []*Issue) { + defer wg.Done() + ctx := context.Background() + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: opts.GithubToken}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + total := 0 + callOpts := &github.IssueListByRepoOptions{State: "all"} + + var lastEntry Issue + if err := db.Where("repository_id = ?", target.ProjectURL()).Order("updated_at desc").First(&lastEntry).Error; err == nil { + callOpts.Since = lastEntry.UpdatedAt + } else { + logger().Warn("failed to get last entry", zap.Error(err)) + } + + for { + issues, resp, err := client.Issues.ListByRepo(ctx, target.Namespace(), target.Project(), callOpts) + if err != nil { + log.Fatal(err) + return + } + total += len(issues) + logger().Debug("paginate", + zap.String("provider", "github"), + zap.String("repo", target.ProjectURL()), + zap.Int("new-issues", len(issues)), + zap.Int("total-issues", total), + ) + normalizedIssues := []*Issue{} + for _, issue := range issues { + normalizedIssues = append(normalizedIssues, fromGithubIssue(issue)) + } + out <- normalizedIssues + if resp.NextPage == 0 { + break + } + callOpts.Page = resp.NextPage + } + if rateLimits, _, err := client.RateLimits(ctx); err == nil { + logger().Debug("github API rate limiting", zap.Stringer("limit", rateLimits.GetCore())) + } +} + +func fromGithubUser(input *github.User) *Account { + name := input.GetName() + if name == "" { + name = input.GetLogin() + } + return &Account{ + Base: Base{ + ID: input.GetLogin(), + CreatedAt: input.GetCreatedAt().Time, + UpdatedAt: input.GetUpdatedAt().Time, + }, + Provider: &Provider{ + Base: Base{ + ID: "github", // FIXME: support multiple github instances + }, + Driver: GithubDriver, + }, + URL: input.GetURL(), + Location: input.GetLocation(), + Company: input.GetCompany(), + Blog: input.GetBlog(), + Email: input.GetEmail(), + AvatarURL: input.GetAvatarURL(), + Login: input.GetLogin(), + FullName: name, + } +} + +func fromGithubRepository(input *github.Repository) *Repository { + panic("not implemented") +} + +func fromGithubRepositoryURL(input string) *Repository { + return &Repository{ + Base: Base{ + ID: input, + }, + URL: input, + Provider: &Provider{ + Base: Base{ + ID: "github", // FIXME: support multiple github instances + }, + Driver: GithubDriver, + }, + } +} + +func fromGithubMilestone(input *github.Milestone) *Milestone { + if input == nil { + return nil + } + parts := strings.Split(input.GetHTMLURL(), "/") + return &Milestone{ + Base: Base{ + ID: input.GetURL(), // FIXME: make it smaller + CreatedAt: input.GetCreatedAt(), + UpdatedAt: input.GetUpdatedAt(), + }, + URL: input.GetURL(), + Title: input.GetTitle(), + Description: input.GetDescription(), + ClosedAt: input.GetClosedAt(), + DueOn: input.GetDueOn(), + Creator: fromGithubUser(input.GetCreator()), + Repository: fromGithubRepositoryURL(strings.Join(parts[0:len(parts)-2], "/")), + } +} + +func fromGithubLabel(input *github.Label) *Label { + if input == nil { + return nil + } + return &Label{ + Base: Base{ + ID: input.GetURL(), // FIXME: make it smaller + }, + Name: input.GetName(), + Color: input.GetColor(), + Description: input.GetDescription(), + URL: input.GetURL(), + } +} + +func fromGithubIssue(input *github.Issue) *Issue { + parts := strings.Split(input.GetHTMLURL(), "/") + url := strings.Replace(input.GetHTMLURL(), "/pull/", "/issues/", -1) + + issue := &Issue{ + Base: Base{ + ID: url, + CreatedAt: input.GetCreatedAt(), + UpdatedAt: input.GetUpdatedAt(), + }, + CompletedAt: input.GetClosedAt(), + Repository: fromGithubRepositoryURL(strings.Join(parts[0:len(parts)-2], "/")), + Title: input.GetTitle(), + State: input.GetState(), + Body: input.GetBody(), + IsPR: input.PullRequestLinks != nil, + URL: url, + IsLocked: input.GetLocked(), + Comments: input.GetComments(), + Upvotes: *input.Reactions.PlusOne, + Downvotes: *input.Reactions.MinusOne, + Labels: make([]*Label, 0), + Assignees: make([]*Account, 0), + Author: fromGithubUser(input.User), + Milestone: fromGithubMilestone(input.Milestone), + + /* + IsOrphan bool `json:"is-orphan"` + IsHidden bool `json:"is-hidden"` + BaseWeight int `json:"base-weight"` + Weight int `json:"weight"` + IsEpic bool `json:"is-epic"` + HasEpic bool `json:"has-epic"` + + // internal + Parents []*Issue `json:"-" gorm:"-"` + Children []*Issue `json:"-" gorm:"-"` + Duplicates []*Issue `json:"-" gorm:"-"` + */ + + } + for _, label := range input.Labels { + issue.Labels = append(issue.Labels, fromGithubLabel(&label)) + } + for _, assignee := range input.Assignees { + issue.Assignees = append(issue.Assignees, fromGithubUser(assignee)) + } + return issue +} diff --git a/gitlab.go b/gitlab.go new file mode 100644 index 000000000..a148eee5c --- /dev/null +++ b/gitlab.go @@ -0,0 +1,215 @@ +package main + +import ( + "fmt" + "net/url" + "strings" + "sync" + "time" + + gitlab "github.com/xanzy/go-gitlab" + "go.uber.org/zap" +) + +func gitlabPull(target Target, wg *sync.WaitGroup, opts *pullOptions, out chan []*Issue) { + defer wg.Done() + client := gitlab.NewClient(nil, opts.GitlabToken) + client.SetBaseURL(fmt.Sprintf("%s/api/v4", target.ProviderURL())) + total := 0 + gitlabOpts := &gitlab.ListProjectIssuesOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 30, + Page: 1, + }, + } + + var lastEntry Issue + if err := db.Where("repository_id = ?", target.ProjectURL()).Order("updated_at desc").First(&lastEntry).Error; err == nil { + gitlabOpts.UpdatedAfter = &lastEntry.UpdatedAt + } + + // FIXME: fetch PRs + + for { + issues, resp, err := client.Issues.ListProjectIssues(target.Path(), gitlabOpts) + if err != nil { + logger().Error("failed to pull issues", zap.Error(err)) + return + } + total += len(issues) + logger().Debug("paginate", + zap.String("provider", "gitlab"), + zap.String("repo", target.ProjectURL()), + zap.Int("new-issues", len(issues)), + zap.Int("total-issues", total), + ) + normalizedIssues := []*Issue{} + for _, issue := range issues { + normalizedIssues = append(normalizedIssues, fromGitlabIssue(issue)) + } + out <- normalizedIssues + if resp.NextPage == 0 { + break + } + gitlabOpts.ListOptions.Page = resp.NextPage + } +} + +func fromGitlabIssue(input *gitlab.Issue) *Issue { + repoURL := input.Links.Project + if repoURL == "" { + repoURL = strings.Replace(input.WebURL, fmt.Sprintf("/issues/%d", input.IID), "", -1) + } + + //out, _ := json.MarshalIndent(input, "", " ") + //fmt.Println(string(out)) + + repo := fromGitlabRepositoryURL(repoURL) + issue := &Issue{ + Base: Base{ + ID: input.WebURL, + CreatedAt: *input.CreatedAt, + UpdatedAt: *input.UpdatedAt, + }, + Repository: repo, + Title: input.Title, + State: input.State, + Body: input.Description, + IsPR: false, + URL: input.WebURL, + IsLocked: false, // not supported on GitLab + Comments: 0, // not supported directly + Upvotes: input.Upvotes, + Downvotes: input.Downvotes, + Labels: make([]*Label, 0), + Assignees: make([]*Account, 0), + Author: fromGitlabFakeUser(repo.Provider, input.Author), + Milestone: fromGitlabMilestone(repo, input.Milestone), + /* + IsOrphan bool `json:"is-orphan"` + IsHidden bool `json:"is-hidden"` + BaseWeight int `json:"base-weight"` + Weight int `json:"weight"` + IsEpic bool `json:"is-epic"` + HasEpic bool `json:"has-epic"` + + // internal + Parents []*Issue `json:"-" gorm:"-"` + Children []*Issue `json:"-" gorm:"-"` + Duplicates []*Issue `json:"-" gorm:"-"` + */ + } + if input.ClosedAt != nil { + issue.CompletedAt = *input.ClosedAt + } + for _, label := range input.Labels { + issue.Labels = append(issue.Labels, fromGitlabLabelname(repo, label)) + } + //issue.Assignees = append(issue.Assignees, fromGitlabFakeUser(input.Assignee)) + for _, assignee := range input.Assignees { + issue.Assignees = append(issue.Assignees, fromGitlabFakeUser(repo.Provider, assignee)) + } + return issue +} + +func fromGitlabLabelname(repository *Repository, name string) *Label { + url := fmt.Sprintf("%s/labels/%s", repository.URL, name) + return &Label{ + Base: Base{ + ID: url, + }, + Name: name, + Color: "aaaacc", + URL: url, + //Description: input.GetDescription(), + } +} + +type gitlabFakeUser struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + State string `json:"state"` + CreatedAt *time.Time `json:"created_at"` +} + +func fromGitlabFakeUser(provider *Provider, input gitlabFakeUser) *Account { + name := input.Name + if name == "" { + name = input.Username + } + url := fmt.Sprintf("%s/%s", provider.URL, input.Username) + account := Account{ + Base: Base{ + ID: url, + //UpdatedAt: input.UpdatedAt.Time, + }, + Provider: &Provider{ + Base: Base{ + ID: "gitlab", // FIXME: support multiple gitlab instances + }, + Driver: GitlabDriver, + }, + Email: input.Email, + FullName: name, + Login: input.Username, + URL: url, + // State: input.State, + + //Location: input.GetLocation(), + //Company: input.GetCompany(), + //Blog: input.GetBlog(), + //AvatarURL: input.GetAvatarURL(), + } + if input.CreatedAt != nil { + account.CreatedAt = *input.CreatedAt + } + + return &account +} + +func fromGitlabRepositoryURL(input string) *Repository { + u, err := url.Parse(input) + if err != nil { + logger().Warn("invalid repository URL", zap.String("URL", input)) + return nil + } + providerURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host) + return &Repository{ + Base: Base{ + ID: input, + }, + URL: input, + Provider: &Provider{ + Base: Base{ + ID: "gitlab", // FIXME: support multiple gitlab instances + }, + URL: providerURL, + Driver: GitlabDriver, + }, + } +} + +func fromGitlabMilestone(repository *Repository, input *gitlab.Milestone) *Milestone { + if input == nil { + return nil + } + url := fmt.Sprintf("%s/milestones/%d", repository.URL, input.ID) + milestone := Milestone{ + Base: Base{ + ID: url, + CreatedAt: *input.CreatedAt, + UpdatedAt: *input.UpdatedAt, + }, + URL: url, + Title: input.Title, + Description: input.Description, + } + if input.DueDate != nil { + milestone.DueOn = time.Time(*input.DueDate) + } + // startdate // FIXME: todo + // state // FIXME: todo + return &milestone +} diff --git a/go.mod b/go.mod index 2c5b8396d..6c6cc8f85 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/jinzhu/gorm v1.9.1 github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect github.com/jinzhu/now v0.0.0-20180511015916-ed742868f2ae // indirect - github.com/lib/pq v1.0.0 // indirect + github.com/lib/pq v1.0.0 github.com/mattn/go-sqlite3 v1.9.0 github.com/mitchellh/mapstructure v1.1.2 // indirect github.com/pkg/errors v0.8.0 diff --git a/go.sum b/go.sum index f445a2e77..1e34c04d2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ cloud.google.com/go v0.29.0 h1:gv/9Wwq5WPVIGaROMQg8tw4jLFFiyacODxEIrlz0wTw= cloud.google.com/go v0.29.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.30.0 h1:xKvyLgk56d0nksWq49J0UyGEeUIicTl4+UBiX1NPX9g= cloud.google.com/go v0.30.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -13,6 +14,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 h1:BZGp1dbKFjqlGmxEpwkDpCWNxVwEYnUPoncIzLiHlPo= github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= +github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f h1:WH0w/R4Yoey+04HhFxqZ6VX6I0d7RMyw5aXQ9UTvQPs= github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= @@ -91,6 +93,7 @@ go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4 h1:Vk3wNqEZwyGyei9yq5ekj7frek2u7HUfffJ1/opblzc= golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e h1:IzypfodbhbnViNUO/MEh0FzCUooG97cIGfdggUrUSyU= golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181003013248-f5e5bdd77824 h1:MkjFNbaZJyH98M67Q3umtwZ+EdVdrNJLqSwZp5vcv60= diff --git a/graphviz.go b/graphviz.go deleted file mode 100644 index afbba1056..000000000 --- a/graphviz.go +++ /dev/null @@ -1,231 +0,0 @@ -package main - -import ( - "fmt" - "math" - "sort" - - "github.com/awalterschulze/gographviz" - "github.com/spf13/pflag" - "github.com/spf13/viper" - "go.uber.org/zap" -) - -type graphOptions struct { - DebugGraph bool `mapstructure:"debug-graph"` - NoCompress bool `mapstructure:"no-compress"` - DarkTheme bool `mapstructure:"dark-theme"` - ShowClosed bool `mapstructure:"show-closed"` - ShowOrphans bool `mapstructure:"show-orphans"` - EpicLabel string `mapstructure:"epic-label"` - Targets []string -} - -func graphSetupFlags(flags *pflag.FlagSet, opts *graphOptions) { - flags.BoolVarP(&opts.ShowClosed, "show-closed", "", false, "show closed issues") - flags.BoolVarP(&opts.DebugGraph, "debug-graph", "", false, "debug graph") - flags.BoolVarP(&opts.ShowOrphans, "show-orphans", "", false, "show issues not linked to an epic") - flags.BoolVarP(&opts.NoCompress, "no-compress", "", false, "do not compress graph (no overlap)") - flags.BoolVarP(&opts.DarkTheme, "dark-theme", "", false, "dark theme") - flags.StringVarP(&opts.EpicLabel, "epic-label", "", "epic", "label used for epics (empty means issues with dependencies but without dependants)") - viper.BindPFlags(flags) -} - -func graphvizRender(issues Issues, opts *graphOptions) (string, error) { - var ( - stats = map[string]int{ - "nodes": 0, - "edges": 0, - "hidden": 0, - "subgraphs": 0, - } - invisStyle = map[string]string{"style": "invis", "label": escape("")} - weightMap = map[int]bool{} - weights = []int{} - ) - if opts.DebugGraph { - invisStyle = map[string]string{} - } - for _, issue := range issues { - if issue.Hidden { - stats["hidden"]++ - continue - } - weightMap[issue.Weight()] = true - } - for weight := range weightMap { - weights = append(weights, weight) - } - sort.Ints(weights) - - // initialize graph - g := gographviz.NewGraph() - panicIfErr(g.SetName("G")) - attrs := map[string]string{} - attrs["truecolor"] = "true" - attrs["rankdir"] = "RL" - attrs["constraint"] = "true" - attrs["compound"] = "true" - if !opts.NoCompress { - attrs["center"] = "true" - attrs["ranksep"] = "0.3" - attrs["nodesep"] = "0.1" - attrs["margin"] = "0.2" - attrs["sep"] = "-0.7" - attrs["constraint"] = "false" - attrs["splines"] = "true" - attrs["overlap"] = "compress" - } - if opts.DarkTheme { - attrs["bgcolor"] = "black" - } - - for k, v := range attrs { - panicIfErr(g.AddAttr("G", k, v)) - } - panicIfErr(g.SetDir(true)) - - // issue nodes - issueNumbers := []string{} - for _, issue := range issues { - issueNumbers = append(issueNumbers, issue.URL) - } - sort.Strings(issueNumbers) - - orphansWithoutLinks := 0 - for _, id := range issueNumbers { - issue := issues[id] - if issue.Hidden { - continue - } - if len(issue.DependsOn) == 0 && len(issue.Blocks) == 0 { - orphansWithoutLinks++ - } - } - orphansCols := int(math.Ceil(math.Sqrt(float64(orphansWithoutLinks)) / 2)) - colIndex := 0 - hasOrphansWithLinks := false - for _, id := range issueNumbers { - issue := issues[id] - if issue.Hidden { - continue - } - parent := fmt.Sprintf("cluster_weight_%d", issue.Weight()) - if issue.IsOrphan || !issue.LinkedWithEpic { - if len(issue.DependsOn) > 0 || len(issue.Blocks) > 0 { - parent = "cluster_orphans_with_links" - hasOrphansWithLinks = true - } else { - parent = fmt.Sprintf("cluster_orphans_without_links_%d", colIndex%orphansCols) - colIndex++ - } - } - - panicIfErr(issue.AddNodeToGraph(g, parent)) - stats["nodes"]++ - } - - // issue relationships - for _, issue := range issues { - panicIfErr(issue.AddEdgesToGraph(g)) - stats["edges"]++ - } - - // orphans cluster and placeholder - if orphansWithoutLinks > 0 { - panicIfErr(g.AddSubGraph( - "G", - "cluster_orphans_without_links", - map[string]string{"label": escape("orphans without links"), "style": "dashed"}, - )) - stats["subgraphs"]++ - - panicIfErr(g.AddSubGraph( - "cluster_orphans_without_links", - "cluster_orphans_without_links_0", - invisStyle, - )) - stats["subgraphs"]++ - for i := 0; i < orphansCols; i++ { - panicIfErr(g.AddNode( - fmt.Sprintf("cluster_orphans_without_links_%d", i), - fmt.Sprintf("placeholder_orphans_without_links_%d", i), - invisStyle, - )) - stats["nodes"]++ - } - - panicIfErr(g.AddEdge( - fmt.Sprintf("placeholder_%d", weights[len(weights)-1]), - "placeholder_orphans_without_links_0", - true, - invisStyle, - )) - stats["edges"]++ - - for i := 1; i < orphansCols; i++ { - panicIfErr(g.AddSubGraph( - "cluster_orphans_without_links", - fmt.Sprintf("cluster_orphans_without_links_%d", i), - invisStyle, - )) - stats["subgraphs"]++ - panicIfErr(g.AddEdge( - fmt.Sprintf("placeholder_orphans_without_links_%d", i-1), - fmt.Sprintf("placeholder_orphans_without_links_%d", i), - true, - invisStyle, - )) - stats["edges"]++ - } - } - if hasOrphansWithLinks { - attrs := map[string]string{} - attrs["label"] = escape("orphans with links") - attrs["style"] = "dashed" - panicIfErr(g.AddSubGraph("G", "cluster_orphans_with_links", attrs)) - stats["subgraphs"]++ - - panicIfErr(g.AddNode("cluster_orphans_with_links", "placeholder_orphans_with_links", invisStyle)) - stats["nodes"]++ - - panicIfErr(g.AddEdge( - "placeholder_orphans_with_links", - fmt.Sprintf("placeholder_%d", weights[0]), - true, - invisStyle, - )) - stats["edges"]++ - } - - // set weights clusters and placeholders - for _, weight := range weights { - clusterName := fmt.Sprintf("cluster_weight_%d", weight) - attrs := invisStyle - attrs["rank"] = "same" - panicIfErr(g.AddSubGraph("G", clusterName, attrs)) - stats["subgraphs"]++ - - attrs = invisStyle - attrs["shape"] = "none" - attrs["label"] = fmt.Sprintf(`"weight=%d"`, weight) - panicIfErr(g.AddNode( - clusterName, - fmt.Sprintf("placeholder_%d", weight), - attrs, - )) - stats["nodes"]++ - } - for i := 0; i < len(weights)-1; i++ { - panicIfErr(g.AddEdge( - fmt.Sprintf("placeholder_%d", weights[i]), - fmt.Sprintf("placeholder_%d", weights[i+1]), - true, - invisStyle, - )) - stats["edges"]++ - } - - logger().Debug("graph stats", zap.Any("stats", stats)) - return g.String(), nil -} diff --git a/issue.go b/issue.go index e3a619c42..f195062a3 100644 --- a/issue.go +++ b/issue.go @@ -1,184 +1,109 @@ package main import ( - "encoding/json" "fmt" - "html" "log" - "net/url" - "regexp" - "strconv" "strings" - "time" - - "github.com/awalterschulze/gographviz" - "github.com/google/go-github/github" - "github.com/spf13/viper" - gitlab "github.com/xanzy/go-gitlab" ) -var debugFetch = false +func (i Issue) GetRelativeURL(target string) string { + if strings.Contains(target, "://") { + return normalizeURL(target) + } -type Provider string + if target[0] == '#' { + return fmt.Sprintf("%s/issues/%s", i.Repository.URL, target[1:]) + } -const ( - UnknownProvider Provider = "unknown" - GitHubProvider = "github" - GitLabProvider = "gitlab" -) + target = strings.Replace(target, "#", "/issues/", -1) -type Issue struct { - // proxy - GitHub *github.Issue `json:"-" gorm:"-"` - GitLab *gitlab.Issue `json:"-" gorm:"-"` - - // internal - Provider Provider `json:"provider"` - DependsOn IssueSlice `json:"-" gorm:"-"` - Blocks IssueSlice `json:"-" gorm:"-"` - weightMultiplier int `gorm:"-" json:"-"` - BaseWeight int `json:"-" gorm:"-"` - IsOrphan bool `json:"is-orphan" gorm:"-"` - Hidden bool `json:"is-hidden" gorm:"-"` - Duplicates []string `json:"duplicates" gorm:"-"` - LinkedWithEpic bool `json:"is-linked-with-an-epic" gorm:"-"` - Errors []error `json:"errors" gorm:"-"` - - // mapping - CreatedAt time.Time `json:"created-at"` - UpdatedAt time.Time `json:"updated-at"` - CompletedAt time.Time `json:"completed-at"` - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` - Body string `json:"body"` - RepoURL string `json:"repo-url"` - URL string `gorm:"primary_key" json:"url"` - Labels []*IssueLabel `gorm:"many2many:issue_labels;" json:"labels"` - Assignees []*Profile `gorm:"many2many:issue_assignees;" json:"assignees"` - IsPR bool `json:"is-pr"` - - // json export fields - JSONChildren []string `gorm:"-" json:"children"` - JSONParents []string `gorm:"-" json:"parents"` - JSONWeight int `gorm:"-" json:"weight"` - - Locked bool `json:"is-locked"` - Author Profile `json:"author"` - AuthorID string `json:"-"` - Comments int `json:"comments"` - Milestone string `json:"milestone"` - Upvotes int `json:"upvotes"` - Downvotes int `json:"downvotes"` + parts := strings.Split(target, "/") + if strings.Contains(parts[0], ".") && isDNSName(parts[0]) { + return fmt.Sprintf("https://%s", target) + } + + return fmt.Sprintf("%s/%s", strings.TrimRight(i.Repository.Provider.URL, "/"), target) +} + +func (i *Issue) PostLoad() { + i.ParentIDs = []string{} + i.ChildIDs = []string{} + i.DuplicateIDs = []string{} + for _, rel := range i.Parents { + i.ParentIDs = append(i.ParentIDs, rel.ID) + } + for _, rel := range i.Children { + i.ChildIDs = append(i.ChildIDs, rel.ID) + } + for _, rel := range i.Duplicates { + i.DuplicateIDs = append(i.DuplicateIDs, rel.ID) + } +} + +func (i Issue) IsClosed() bool { + return i.State == "closed" } -type IssueLabel struct { - ID string `gorm:"primary_key"` - Color string +func (i Issue) IsReady() bool { + return !i.IsOrphan && len(i.Parents) == 0 // FIXME: switch parents with children? } -type Profile struct { - ID string `gorm:"primary_key"` - Name string +func (i Issue) MatchesWithATarget(targets Targets) bool { + return i.matchesWithATarget(targets, 0) } -func FromGitHubIssue(input *github.Issue) *Issue { - body := "" - if input.Body != nil { - body = *input.Body - } - parts := strings.Split(*input.HTMLURL, "/") - authorName := *input.User.Login - if input.User.Name != nil { - authorName = *input.User.Name - } - issue := &Issue{ - CreatedAt: *input.CreatedAt, - UpdatedAt: *input.UpdatedAt, - CompletedAt: input.GetClosedAt(), - Provider: GitHubProvider, - GitHub: input, - Number: *input.Number, - Title: *input.Title, - State: *input.State, - Body: body, - IsPR: input.PullRequestLinks != nil, - URL: strings.Replace(*input.HTMLURL, "/pull/", "/issues/", -1), - RepoURL: strings.Join(parts[0:len(parts)-2], "/"), - Labels: make([]*IssueLabel, 0), - Assignees: make([]*Profile, 0), - Locked: *input.Locked, - Comments: *input.Comments, - Upvotes: *input.Reactions.PlusOne, - Downvotes: *input.Reactions.MinusOne, - Author: Profile{ - ID: *input.User.Login, - Name: authorName, - }, - } - if input.Milestone != nil { - issue.Milestone = *input.Milestone.Title - } - for _, label := range input.Labels { - issue.Labels = append(issue.Labels, &IssueLabel{ - ID: *label.Name, - Color: *label.Color, - }) - } - for _, assignee := range input.Assignees { - name := *assignee.Login - if assignee.Name != nil { - name = *assignee.Name +func (i Issue) matchesWithATarget(targets Targets, depth int) bool { + if depth > 100 { + log.Printf("circular dependency or too deep graph (>100), skipping this node. (issue=%s)", i) + return false + } + + for _, target := range targets { + if target.Issue() != "" { // issue-mode + if target.Canonical() == i.URL { + return true + } + } else { // project-mode + if i.RepositoryID == target.ProjectURL() { + return true + } } - issue.Assignees = append(issue.Assignees, &Profile{ - ID: *assignee.Login, - Name: name, - }) } - return issue -} -func FromGitLabIssue(input *gitlab.Issue) *Issue { - if debugFetch { - out, _ := json.MarshalIndent(input, "", " ") - log.Println(string(out)) - } - repoURL := input.Links.Project - if repoURL == "" { - repoURL = strings.Replace(input.WebURL, fmt.Sprintf("/issues/%d", input.IID), "", -1) - } - issue := &Issue{ - CreatedAt: *input.CreatedAt, - UpdatedAt: *input.UpdatedAt, - Provider: GitLabProvider, - GitLab: input, - Number: input.IID, - Title: input.Title, - State: input.State, - URL: input.WebURL, - Body: input.Description, - RepoURL: repoURL, - Labels: make([]*IssueLabel, 0), - Assignees: make([]*Profile, 0), - } - if issue.State == "opened" { - issue.State = "open" - } - for _, label := range input.Labels { - issue.Labels = append(issue.Labels, &IssueLabel{ - ID: label, - Color: "cccccc", - }) - } - for _, assignee := range input.Assignees { - issue.Assignees = append(issue.Assignees, &Profile{ - Name: assignee.Name, - ID: assignee.Username, - }) - } - return issue + for _, parent := range i.Parents { + if parent.matchesWithATarget(targets, depth+1) { + return true + } + } + + for _, child := range i.Children { + if child.matchesWithATarget(targets, depth+1) { + return true + } + } + + return false } +/* +import ( + "encoding/json" + "fmt" + "html" + "log" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/awalterschulze/gographviz" + "github.com/google/go-github/github" + "github.com/spf13/viper" + gitlab "github.com/xanzy/go-gitlab" +) + + + func (i *Issue) WithJSONFields() *Issue { i.JSONWeight = i.Weight() if len(i.Blocks) > 0 { @@ -196,14 +121,6 @@ func (i *Issue) WithJSONFields() *Issue { return i } -func (i Issue) Path() string { - u, err := url.Parse(i.URL) - if err != nil { - return "" - } - parts := strings.Split(u.Path, "/") - return strings.Join(parts[:len(parts)-2], "/") -} type IssueSlice []*Issue @@ -259,88 +176,13 @@ func (i Issue) Owner() string { return strings.Split(i.URL, "/")[4] } -func (i Issue) IsClosed() bool { - return i.State == "closed" -} - -func (i Issue) IsReady() bool { - return !i.IsOrphan && len(i.DependsOn) == 0 -} -func (i Issue) NodeName() string { - return fmt.Sprintf(`%s#%d`, i.Path()[1:], i.Number) -} -func (i Issue) NodeTitle() string { - title := fmt.Sprintf("%s: %s", i.NodeName(), i.Title) - title = strings.Replace(title, "|", "-", -1) - title = strings.Replace(html.EscapeString(wrap(title, 20)), "\n", "
", -1) - labels := []string{} - for _, label := range i.Labels { - switch label.ID { - case "t/step", "t/epic", "epic": - continue - } - labels = append(labels, fmt.Sprintf(`%s`, label.Color, label.ID)) - } - labelsText := "" - if len(labels) > 0 { - labelsText = "" + strings.Join(labels, "") + "
" - } - assigneeText := "" - if len(i.Assignees) > 0 { - assignees := []string{} - for _, assignee := range i.Assignees { - assignees = append(assignees, assignee.ID) - } - assigneeText = fmt.Sprintf(`@%s`, strings.Join(assignees, ", @")) - } - errorsText := "" - if len(i.Errors) > 0 { - errors := []string{} - for _, err := range i.Errors { - errors = append(errors, err.Error()) - } - errorsText = fmt.Sprintf(`ERR: %s`, strings.Join(errors, ";
ERR: ")) - } - return fmt.Sprintf(`<%s%s%s
%s
>`, title, labelsText, assigneeText, errorsText) -} - -func normalizeURL(input string) string { - parts := strings.Split(input, "://") - output := fmt.Sprintf("%s://%s", parts[0], strings.Replace(parts[1], "//", "/", -1)) - output = strings.TrimRight(output, "#") - output = strings.TrimRight(output, "/") - return output -} - -func (i Issue) GetRelativeIssueURL(target string) string { - if strings.Contains(target, "://") { - return normalizeURL(target) - } - - if target[0] == '#' { - return fmt.Sprintf("%s/issues/%s", i.RepoURL, target[1:]) - } - - target = strings.Replace(target, "#", "/issues/", -1) - - parts := strings.Split(target, "/") - if strings.Contains(parts[0], ".") && isDNSName(parts[0]) { - return fmt.Sprintf("https://%s", target) - } - - return fmt.Sprintf("%s/%s", strings.TrimRight(i.ProviderURL(), "/"), target) -} func (i Issue) BlocksAnEpic() bool { return i.blocksAnEpic(0) } -func (i Issue) String() string { - out, _ := json.Marshal(i) - return string(out) -} func (i Issue) blocksAnEpic(depth int) bool { if depth > 100 { @@ -388,88 +230,9 @@ func (i Issue) WeightMultiplier() int { return multiplier } -func (i Issue) AddEdgesToGraph(g *gographviz.Graph) error { - if i.Hidden { - return nil - } - for _, dependency := range i.DependsOn { - if dependency.Hidden { - continue - } - attrs := map[string]string{} - attrs["color"] = "lightblue" - //attrs["label"] = "depends on" - //attrs["style"] = "dotted" - attrs["dir"] = "none" - if i.IsClosed() || dependency.IsClosed() { - attrs["color"] = "grey" - attrs["style"] = "dotted" - } - if dependency.IsReady() { - attrs["color"] = "pink" - } - if i.IsEpic() { - attrs["color"] = "orange" - attrs["style"] = "dashed" - } - //log.Print("edge", escape(i.URL), "->", escape(dependency.URL)) - if err := g.AddEdge( - escape(i.URL), - escape(dependency.URL), - true, - attrs, - ); err != nil { - return err - } - } - return nil -} - -func (i Issue) AddNodeToGraph(g *gographviz.Graph, parent string) error { - attrs := map[string]string{} - attrs["label"] = i.NodeTitle() - //attrs["xlabel"] = "" - attrs["shape"] = "record" - attrs["style"] = `"rounded,filled"` - attrs["color"] = "lightblue" - attrs["href"] = escape(i.URL) - if i.IsEpic() { - attrs["shape"] = "oval" - } - - switch { - - case i.IsClosed(): - attrs["color"] = `"#cccccc33"` - - case i.IsReady(): - attrs["color"] = "pink" - - case i.IsEpic(): - attrs["color"] = "orange" - attrs["style"] = `"rounded,filled,bold"` - - case i.IsOrphan || !i.LinkedWithEpic: - attrs["color"] = "gray" - } - - return g.AddNode( - parent, - escape(i.URL), - attrs, - ) -} func (issues Issues) prepare(includePRs bool) error { - var ( - dependsOnRegex, _ = regexp.Compile(`(?i)(require|requires|blocked by|block by|depend on|depends on|parent of) ([a-z0-9:/_.-]+issues/[0-9]+|[a-z0-9:/_.-]+#[0-9]+|[a-z0-9/_-]*#[0-9]+)`) - blocksRegex, _ = regexp.Compile(`(?i)(blocks|block|address|addresses|part of|child of|fix|fixes) ([a-z0-9:/_.-]+issues/[0-9]+|[a-z0-9:/_.-]+#[0-9]+|[a-z0-9/_-]*#[0-9]+)`) - isDuplicateRegex, _ = regexp.Compile(`(?i)(duplicates|duplicate|dup of|dup|duplicate of) ([a-z0-9:/_.-]+issues/[0-9]+|[a-z0-9:/_.-]+#[0-9]+|[a-z0-9/_-]*#[0-9]+)`) - weightMultiplierRegex, _ = regexp.Compile(`(?i)(depviz.weight_multiplier[:= ]+)([0-9]+)`) - baseWeightRegex, _ = regexp.Compile(`(?i)(depviz.base_weight|depviz.weight)[:= ]+([0-9]+)`) - hideFromRoadmapRegex, _ = regexp.Compile(`(?i)(depviz.hide)`) // FIXME: use label - ) for _, issue := range issues { issue.DependsOn = make([]*Issue, 0) @@ -478,54 +241,6 @@ func (issues Issues) prepare(includePRs bool) error { issue.weightMultiplier = 1 issue.BaseWeight = 1 } - for _, issue := range issues { - if issue.Body == "" { - continue - } - - if match := isDuplicateRegex.FindStringSubmatch(issue.Body); match != nil { - issue.Duplicates = append(issue.Duplicates, issue.GetRelativeIssueURL(match[len(match)-1])) - } - - if match := weightMultiplierRegex.FindStringSubmatch(issue.Body); match != nil { - issue.weightMultiplier, _ = strconv.Atoi(match[len(match)-1]) - } - - if match := hideFromRoadmapRegex.FindStringSubmatch(issue.Body); match != nil { - delete(issues, issue.URL) - continue - } - - if match := baseWeightRegex.FindStringSubmatch(issue.Body); match != nil { - issue.BaseWeight, _ = strconv.Atoi(match[len(match)-1]) - } - - for _, match := range dependsOnRegex.FindAllStringSubmatch(issue.Body, -1) { - num := issue.GetRelativeIssueURL(match[len(match)-1]) - dep, found := issues[num] - if !found { - issue.Errors = append(issue.Errors, fmt.Errorf("parent %q not found", num)) - continue - } - issue.DependsOn = append(issue.DependsOn, dep) - issues[num].Blocks = append(dep.Blocks, issue) - issue.IsOrphan = false - issues[num].IsOrphan = false - } - - for _, match := range blocksRegex.FindAllStringSubmatch(issue.Body, -1) { - num := issue.GetRelativeIssueURL(match[len(match)-1]) - dep, found := issues[num] - if !found { - issue.Errors = append(issue.Errors, fmt.Errorf("child %q not found", num)) - continue - } - issues[num].DependsOn = append(dep.DependsOn, issue) - issue.Blocks = append(issue.Blocks, dep) - issue.IsOrphan = false - issues[num].IsOrphan = false - } - } for _, issue := range issues { if len(issue.Duplicates) > 0 { issue.Hidden = true @@ -620,3 +335,4 @@ func (issues Issues) HasNonOrphans() bool { } return false } +*/ diff --git a/issue_test.go b/issue_test.go index 556ff4ca4..a2fb2ea63 100644 --- a/issue_test.go +++ b/issue_test.go @@ -2,10 +2,15 @@ package main import "fmt" -func ExampleIssue_GetRelativeIssueURL() { +func ExampleIssue_GetRelativeURL() { issue := Issue{ - URL: "https://github.com/moul/depviz/issues/42", - RepoURL: "https://github.com/moul/depviz", + URL: "https://github.com/moul/depviz/issues/42", + Repository: &Repository{ + URL: "https://github.com/moul/depviz", + Provider: &Provider{ + URL: "https://github.com/", + }, + }, } for _, target := range []string{ "#43", @@ -20,12 +25,17 @@ func ExampleIssue_GetRelativeIssueURL() { "gitlab.com/test2/issues/52", "gitlab.com/test2/test3/test4/issues/53", } { - fmt.Printf("%-42s -> %s\n", target, issue.GetRelativeIssueURL(target)) + fmt.Printf("%-42s -> %s\n", target, issue.GetRelativeURL(target)) } issue = Issue{ - URL: "https://gitlab.com/moul/depviz/issues/42", - RepoURL: "https://gitlab.com/moul/depviz", + URL: "https://gitlab.com/moul/depviz/issues/42", + Repository: &Repository{ + URL: "https://gitlab.com/moul/depviz", + Provider: &Provider{ + URL: "https://gitlab.com/", + }, + }, } for _, target := range []string{ "#43", @@ -40,7 +50,7 @@ func ExampleIssue_GetRelativeIssueURL() { "github.com/test2/issues/52", "github.com/test2/test3/test4/issues/53", } { - fmt.Printf("%-42s -> %s\n", target, issue.GetRelativeIssueURL(target)) + fmt.Printf("%-42s -> %s\n", target, issue.GetRelativeURL(target)) } // Output: diff --git a/issues.go b/issues.go new file mode 100644 index 000000000..a3b03d7d3 --- /dev/null +++ b/issues.go @@ -0,0 +1,24 @@ +package main + +type Issues []*Issue + +func (issues Issues) Get(id string) *Issue { + for _, issue := range issues { + if issue.ID == id { + return issue + } + } + return nil +} + +func (issues Issues) FilterByTargets(targets []Target) Issues { + filtered := Issues{} + + for _, issue := range issues { + if issue.MatchesWithATarget(targets) { + filtered = append(filtered, issue) + } + } + + return filtered +} diff --git a/main.go b/main.go index 02d85df48..53d5c958d 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,26 @@ func newRootCommand() *cobra.Command { } } + // fill global options + if err := viper.Unmarshal(&globalGraphOptions); err != nil { + return err + } + if err := viper.Unmarshal(&globalRunOptions); err != nil { + return err + } + if err := viper.Unmarshal(&globalPullOptions); err != nil { + return err + } + if err := viper.Unmarshal(&globalWebOptions); err != nil { + return err + } + if err := viper.Unmarshal(&globalAirtableOptions); err != nil { + return err + } + if err := viper.Unmarshal(&globalDBOptions); err != nil { + return err + } + // configure sql dbPath = os.ExpandEnv(dbPath) db, err = gorm.Open("sqlite3", dbPath) @@ -89,8 +109,11 @@ func newRootCommand() *cobra.Command { db.LogMode(verbose) if err := db.AutoMigrate( Issue{}, - IssueLabel{}, - Profile{}, + Label{}, + Account{}, + Milestone{}, + Repository{}, + Provider{}, ).Error; err != nil { return err } @@ -99,10 +122,11 @@ func newRootCommand() *cobra.Command { } cmd.AddCommand( newPullCommand(), - newRunCommand(), newDBCommand(), - newWebCommand(), newAirtableCommand(), + newGraphCommand(), + newRunCommand(), + newWebCommand(), ) viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) diff --git a/models.go b/models.go new file mode 100644 index 000000000..4d9837b23 --- /dev/null +++ b/models.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/json" + "time" + + "github.com/lib/pq" +) + +// +// Base +// + +type Base struct { + ID string `gorm:"primary_key" json:"id"` + CreatedAt time.Time `json:"created-at,omitempty"` + UpdatedAt time.Time `json:"updated-at,omitempty"` + Errors pq.StringArray `json:"errors,omitempty" gorm:"type:varchar[]"` +} + +// +// Repository +// + +type Repository struct { + Base + + // base fields + URL string `json:"url"` + Title string `json:"name"` + Description string `json:"description"` + Homepage string `json:"homepage"` + PushedAt time.Time `json:"pushed-at"` + IsFork bool `json:"fork"` + + // relationships + Provider *Provider `json:"provider"` + ProviderID string `json:"provider-id"` + Owner *Account `json:"owner"` + OwnerID string `json:"owner-id"` +} + +// +// Provider +// + +type ProviderDriver string + +const ( + UnknownProviderDriver ProviderDriver = "unknown" + GithubDriver = "github" + GitlabDriver = "gitlab" +) + +type Provider struct { + Base + + // base fields + URL string `json:"url"` + Driver string `json:"driver"` // github, gitlab, unknown +} + +// +// Milestone +// + +type Milestone struct { + Base + + // base fields + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + ClosedAt time.Time `json:"closed-at"` + DueOn time.Time `json:"due-on"` + // State string // FIXME: todo + // StartAt time.Time // FIXME: todo + + // relationships + Creator *Account `json:"creator"` + CreatorID string `json:"creator-id"` + Repository *Repository `json:"repository"` + RepositoryID string `json:"repository-id"` +} + +// +// Issue +// + +type Issue struct { + Base + + // base fields + URL string `json:"url"` + CompletedAt time.Time `json:"completed-at"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + IsPR bool `json:"is-pr"` + IsLocked bool `json:"is-locked"` + Comments int `json:"comments"` + Upvotes int `json:"upvotes"` + Downvotes int `json:"downvotes"` + IsOrphan bool `json:"is-orphan"` + IsHidden bool `json:"is-hidden"` + Weight int `json:"weight"` + IsEpic bool `json:"is-epic"` + HasEpic bool `json:"has-epic"` + + // relationships + Repository *Repository `json:"repository"` + RepositoryID string `json:"repository_id"` + Milestone *Milestone `json:"milestone"` + MilestoneID string `json:"milestone_id"` + Author *Account `json:"author"` + AuthorID string `json:"author_id"` + Labels []*Label `gorm:"many2many:issue_labels" json:"labels"` + Assignees []*Account `gorm:"many2many:issue_assignees" json:"assignees"` + Parents []*Issue `json:"-" gorm:"many2many:issue_parents;association_jointable_foreignkey:parent_id"` + Children []*Issue `json:"-" gorm:"many2many:issue_children;association_jointable_foreignkey:child_id"` + Duplicates []*Issue `json:"-" gorm:"many2many:issue_duplicates;association_jointable_foreignkey:duplicate_id"` + + // internal + ChildIDs []string `json:"child_ids" gorm:"-"` + ParentIDs []string `json:"parent_ids" gorm:"-"` + DuplicateIDs []string `json:"duplicate_ids" gorm:"-"` +} + +func (i Issue) String() string { + out, _ := json.Marshal(i) + return string(out) +} + +// +// Label +// + +type Label struct { + Base + + // base fields + URL string `json:"url"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +// +// Account +// + +type Account struct { + Base + + // base fields + URL string `json:"url"` + Login string `json:"login"` + FullName string `json:"fullname"` + Type string `json:"type"` + Bio string `json:"bio"` + Location string `json:"location"` + Company string `json:"company"` + Blog string `json:"blog"` + Email string `json:"email"` + AvatarURL string `json:"avatar-url"` + + // relationships + Provider *Provider `json:"provider"` + ProviderID string `json:"provider-id"` +} + +// FIXME: create a User struct to handle multiple accounts and aliases diff --git a/models_airtable.go b/models_airtable.go new file mode 100644 index 000000000..b87d39cd7 --- /dev/null +++ b/models_airtable.go @@ -0,0 +1,610 @@ +package main + +import ( + "encoding/json" + "strings" + "time" + + "github.com/brianloveswords/airtable" +) + +type AirtableBase struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created-at"` + UpdatedAt time.Time `json:"updated-at"` + Errors string `json:"errors"` +} + +type airtableState int + +type AirtableRecords []interface{} + +type AirtableEntry interface { + ToRecord(cache AirtableDB) interface{} +} + +const ( + airtableStateUnknown airtableState = iota + airtableStateUnchanged + airtableStateChanged + airtableStateNew +) + +var ( + airtableStateString = map[airtableState]string{ + airtableStateUnknown: "unknown", + airtableStateUnchanged: "unchanged", + airtableStateChanged: "changed", + airtableStateNew: "new", + } +) + +// +// provider +// + +type ProviderRecord struct { + State airtableState `json:"-"` // internal + + airtable.Record // provides ID, CreatedTime + Fields struct { + // base + AirtableBase + + // specific + URL string `json:"url"` + Driver string `json:"driver"` + + // relationship + // n/a + } `json:"fields,omitempty"` +} + +func (r ProviderRecord) String() string { + out, _ := json.Marshal(r) + return string(out) +} + +func (p Provider) ToRecord(cache AirtableDB) *ProviderRecord { + record := ProviderRecord{} + + // base + record.Fields.ID = p.ID + record.Fields.CreatedAt = p.CreatedAt + record.Fields.UpdatedAt = p.UpdatedAt + record.Fields.Errors = strings.Join(p.Errors, ", ") + + // specific + record.Fields.URL = p.URL + record.Fields.Driver = p.Driver + + // relationships + // n/a + + return &record +} + +func (r *ProviderRecord) Equals(n *ProviderRecord) bool { + return true && + // base + r.Fields.ID == n.Fields.ID && + isSameAirtableDate(r.Fields.CreatedAt, n.Fields.CreatedAt) && + isSameAirtableDate(r.Fields.UpdatedAt, n.Fields.UpdatedAt) && + r.Fields.Errors == n.Fields.Errors && + + // specific + r.Fields.URL == n.Fields.URL && + r.Fields.Driver == n.Fields.Driver && + + // relationships + // n/a + + true +} + +type ProviderRecords []ProviderRecord + +func (records ProviderRecords) ByID(id string) string { + for _, record := range records { + if record.Fields.ID == id { + return record.ID + } + } + return "" +} + +// +// label +// + +type LabelRecord struct { + State airtableState `json:"-"` // internal + + airtable.Record // provides ID, CreatedTime + Fields struct { + // base + AirtableBase + + // specific + URL string `json:"url"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` + + // relationship + // n/a + } `json:"fields,omitempty"` +} + +func (r LabelRecord) String() string { + out, _ := json.Marshal(r) + return string(out) +} + +func (p Label) ToRecord(cache AirtableDB) *LabelRecord { + record := LabelRecord{} + + // base + record.Fields.ID = p.ID + record.Fields.CreatedAt = p.CreatedAt + record.Fields.UpdatedAt = p.UpdatedAt + record.Fields.Errors = strings.Join(p.Errors, ", ") + + // specific + record.Fields.URL = p.URL + record.Fields.Name = p.Name + record.Fields.Color = p.Color + record.Fields.Description = p.Description + + // relationships + // n/a + + return &record +} + +func (r *LabelRecord) Equals(n *LabelRecord) bool { + return true && + // base + r.Fields.ID == n.Fields.ID && + isSameAirtableDate(r.Fields.CreatedAt, n.Fields.CreatedAt) && + isSameAirtableDate(r.Fields.UpdatedAt, n.Fields.UpdatedAt) && + r.Fields.Errors == n.Fields.Errors && + + // specific + r.Fields.URL == n.Fields.URL && + r.Fields.Name == n.Fields.Name && + r.Fields.Color == n.Fields.Color && + r.Fields.Description == n.Fields.Description && + + // relationships + // n/a + + true +} + +type LabelRecords []LabelRecord + +func (records LabelRecords) ByID(id string) string { + for _, record := range records { + if record.Fields.ID == id { + return record.ID + } + } + return "" +} + +// +// account +// + +type AccountRecord struct { + State airtableState `json:"-"` // internal + + airtable.Record // provides ID, CreatedTime + Fields struct { + // base + AirtableBase + + // specific + URL string `json:"url"` + Login string `json:"login"` + FullName string `json:"fullname"` + Type string `json:"type"` + Bio string `json:"bio"` + Location string `json:"location"` + Company string `json:"company"` + Blog string `json:"blog"` + Email string `json:"email"` + AvatarURL string `json:"avatar-url"` + + // relationships + Provider []string `json:"provider"` + } `json:"fields,omitempty"` +} + +func (r AccountRecord) String() string { + out, _ := json.Marshal(r) + return string(out) +} + +func (p Account) ToRecord(cache AirtableDB) *AccountRecord { + record := AccountRecord{} + // base + record.Fields.ID = p.ID + record.Fields.CreatedAt = p.CreatedAt + record.Fields.UpdatedAt = p.UpdatedAt + record.Fields.Errors = strings.Join(p.Errors, ", ") + + // specific + record.Fields.URL = p.URL + record.Fields.Login = p.Login + record.Fields.FullName = p.FullName + record.Fields.Type = p.Type + record.Fields.Bio = p.Bio + record.Fields.Location = p.Location + record.Fields.Company = p.Company + record.Fields.Blog = p.Blog + record.Fields.Email = p.Email + record.Fields.AvatarURL = p.AvatarURL + + // relationships + record.Fields.Provider = []string{cache.Providers.ByID(p.Provider.ID)} + + return &record +} + +func (r *AccountRecord) Equals(n *AccountRecord) bool { + return true && + + // base + r.Fields.ID == n.Fields.ID && + isSameAirtableDate(r.Fields.CreatedAt, n.Fields.CreatedAt) && + isSameAirtableDate(r.Fields.UpdatedAt, n.Fields.UpdatedAt) && + r.Fields.Errors == n.Fields.Errors && + + // specific + r.Fields.URL == n.Fields.URL && + r.Fields.Login == n.Fields.Login && + r.Fields.FullName == n.Fields.FullName && + r.Fields.Type == n.Fields.Type && + r.Fields.Bio == n.Fields.Bio && + r.Fields.Location == n.Fields.Location && + r.Fields.Company == n.Fields.Company && + r.Fields.Blog == n.Fields.Blog && + r.Fields.Email == n.Fields.Email && + r.Fields.AvatarURL == n.Fields.AvatarURL && + + // relationships + isSameStringSlice(r.Fields.Provider, n.Fields.Provider) && + + true +} + +type AccountRecords []AccountRecord + +func (records AccountRecords) ByID(id string) string { + for _, record := range records { + if record.Fields.ID == id { + return record.ID + } + } + return "" +} + +// +// repository +// + +type RepositoryRecord struct { + State airtableState `json:"-"` // internal + + airtable.Record // provides ID, CreatedTime + Fields struct { + // base + AirtableBase + + // specific + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Homepage string `json:"homepage"` + PushedAt time.Time `json:"pushed-at"` + IsFork bool `json:"is-fork"` + + // relationships + Provider []string `json:"provider"` + Owner []string `json:"owner"` + } `json:"fields,omitempty"` +} + +func (r RepositoryRecord) String() string { + out, _ := json.Marshal(r) + return string(out) +} + +func (p Repository) ToRecord(cache AirtableDB) *RepositoryRecord { + record := RepositoryRecord{} + + // base + record.Fields.ID = p.ID + record.Fields.CreatedAt = p.CreatedAt + record.Fields.UpdatedAt = p.UpdatedAt + record.Fields.Errors = strings.Join(p.Errors, ", ") + + // specific + record.Fields.URL = p.URL + record.Fields.Title = p.Title + record.Fields.Description = p.Description + record.Fields.Homepage = p.Homepage + record.Fields.PushedAt = p.PushedAt + record.Fields.IsFork = p.IsFork + + // relationships + record.Fields.Provider = []string{cache.Providers.ByID(p.Provider.ID)} + if p.Owner != nil { + record.Fields.Owner = []string{cache.Accounts.ByID(p.Owner.ID)} + } + + return &record +} + +func (r *RepositoryRecord) Equals(n *RepositoryRecord) bool { + return true && + + // base + r.Fields.ID == n.Fields.ID && + isSameAirtableDate(r.Fields.CreatedAt, n.Fields.CreatedAt) && + isSameAirtableDate(r.Fields.UpdatedAt, n.Fields.UpdatedAt) && + r.Fields.Errors == n.Fields.Errors && + + // specific + r.Fields.URL == n.Fields.URL && + r.Fields.Title == n.Fields.Title && + r.Fields.Description == n.Fields.Description && + r.Fields.Homepage == n.Fields.Homepage && + isSameAirtableDate(r.Fields.PushedAt, n.Fields.PushedAt) && + r.Fields.IsFork == n.Fields.IsFork && + + // relationships + isSameStringSlice(r.Fields.Provider, n.Fields.Provider) && + isSameStringSlice(r.Fields.Owner, n.Fields.Owner) && + + true +} + +type RepositoryRecords []RepositoryRecord + +func (records RepositoryRecords) ByID(id string) string { + for _, record := range records { + if record.Fields.ID == id { + return record.ID + } + } + return "" +} + +// +// milestone +// + +type MilestoneRecord struct { + State airtableState `json:"-"` // internal + + airtable.Record // provides ID, CreatedTime + Fields struct { + // base + AirtableBase + + // specific + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + ClosedAt time.Time `json:"closed-at"` + DueOn time.Time `json:"due-on"` + + // relationships + Creator []string `json:"creator"` + Repository []string `json:"repository"` + } `json:"fields,omitempty"` +} + +func (r MilestoneRecord) String() string { + out, _ := json.Marshal(r) + return string(out) +} + +func (p Milestone) ToRecord(cache AirtableDB) *MilestoneRecord { + record := MilestoneRecord{} + // base + record.Fields.ID = p.ID + record.Fields.CreatedAt = p.CreatedAt + record.Fields.UpdatedAt = p.UpdatedAt + record.Fields.Errors = strings.Join(p.Errors, ", ") + + // specific + record.Fields.URL = p.URL + record.Fields.Title = p.Title + record.Fields.Description = p.Description + record.Fields.ClosedAt = p.ClosedAt + record.Fields.DueOn = p.DueOn + + // relationships + if p.Creator != nil { + record.Fields.Creator = []string{cache.Accounts.ByID(p.Creator.ID)} + } + if p.Repository != nil { + record.Fields.Repository = []string{cache.Repositories.ByID(p.Repository.ID)} + } + + return &record +} + +func (r *MilestoneRecord) Equals(n *MilestoneRecord) bool { + return true && + + // base + r.Fields.ID == n.Fields.ID && + isSameAirtableDate(r.Fields.CreatedAt, n.Fields.CreatedAt) && + isSameAirtableDate(r.Fields.UpdatedAt, n.Fields.UpdatedAt) && + r.Fields.Errors == n.Fields.Errors && + + // specific + r.Fields.URL == n.Fields.URL && + r.Fields.Title == n.Fields.Title && + r.Fields.Description == n.Fields.Description && + isSameAirtableDate(r.Fields.ClosedAt, n.Fields.ClosedAt) && + isSameAirtableDate(r.Fields.DueOn, n.Fields.DueOn) && + + // relationships + isSameStringSlice(r.Fields.Creator, n.Fields.Creator) && + isSameStringSlice(r.Fields.Repository, n.Fields.Repository) && + + true +} + +type MilestoneRecords []MilestoneRecord + +func (records MilestoneRecords) ByID(id string) string { + for _, record := range records { + if record.Fields.ID == id { + return record.ID + } + } + return "" +} + +// +// issue +// + +type IssueRecord struct { + State airtableState `json:"-"` // internal + + airtable.Record // provides ID, CreatedTime + Fields struct { + // base + AirtableBase + + // specific + URL string `json:"url"` + CompletedAt time.Time `json:"completed-at"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + IsPR bool `json:"is-pr"` + IsLocked bool `json:"is-locked"` + Comments int `json:"comments"` + Upvotes int `json:"upvotes"` + Downvotes int `json:"downvotes"` + IsOrphan bool `json:"is-orphan"` + IsHidden bool `json:"is-hidden"` + Weight int `json:"weight"` + IsEpic bool `json:"is-epic"` + HasEpic bool `json:"has-epic"` + + // relationships + Repository []string `json:"repository"` + Milestone []string `json:"milestone"` + Author []string `json:"author"` + Labels []string `json:"labels"` + Assignees []string `json:"assignees"` + //Parents []string `json:"-"` + //Children []string `json:"-"` + //Duplicates []string `json:"-"` + } `json:"fields,omitempty"` +} + +func (r IssueRecord) String() string { + out, _ := json.Marshal(r) + return string(out) +} + +func (p Issue) ToRecord(cache AirtableDB) *IssueRecord { + record := IssueRecord{} + // base + record.Fields.ID = p.ID + record.Fields.CreatedAt = p.CreatedAt + record.Fields.UpdatedAt = p.UpdatedAt + record.Fields.Errors = strings.Join(p.Errors, ", ") + + // specific + record.Fields.URL = p.URL + record.Fields.CompletedAt = p.CompletedAt + record.Fields.Title = p.Title + record.Fields.State = p.State + record.Fields.Body = p.Body + record.Fields.IsPR = p.IsPR + record.Fields.IsLocked = p.IsLocked + record.Fields.Comments = p.Comments + record.Fields.Upvotes = p.Upvotes + record.Fields.Downvotes = p.Downvotes + record.Fields.IsOrphan = p.IsOrphan + record.Fields.IsHidden = p.IsHidden + record.Fields.Weight = p.Weight + record.Fields.IsEpic = p.IsEpic + record.Fields.HasEpic = p.HasEpic + + // relationships + record.Fields.Repository = []string{cache.Repositories.ByID(p.Repository.ID)} + if p.Milestone != nil { + record.Fields.Milestone = []string{cache.Milestones.ByID(p.Milestone.ID)} + } + record.Fields.Author = []string{cache.Accounts.ByID(p.Author.ID)} + record.Fields.Labels = []string{} + for _, label := range p.Labels { + record.Fields.Labels = append(record.Fields.Labels, cache.Labels.ByID(label.ID)) + } + record.Fields.Assignees = []string{} + for _, assignee := range p.Assignees { + record.Fields.Assignees = append(record.Fields.Assignees, cache.Accounts.ByID(assignee.ID)) + } + + return &record +} + +func (r *IssueRecord) Equals(n *IssueRecord) bool { + return true && + + // base + r.Fields.ID == n.Fields.ID && + isSameAirtableDate(r.Fields.CreatedAt, n.Fields.CreatedAt) && + isSameAirtableDate(r.Fields.UpdatedAt, n.Fields.UpdatedAt) && + r.Fields.Errors == n.Fields.Errors && + + // specific + r.Fields.URL == n.Fields.URL && + isSameAirtableDate(r.Fields.CompletedAt, n.Fields.CompletedAt) && + r.Fields.Title == n.Fields.Title && + r.Fields.State == n.Fields.State && + r.Fields.Body == n.Fields.Body && + r.Fields.IsPR == n.Fields.IsPR && + r.Fields.IsLocked == n.Fields.IsLocked && + r.Fields.Comments == n.Fields.Comments && + r.Fields.Upvotes == n.Fields.Upvotes && + r.Fields.Downvotes == n.Fields.Downvotes && + r.Fields.IsOrphan == n.Fields.IsOrphan && + r.Fields.IsHidden == n.Fields.IsHidden && + r.Fields.Weight == n.Fields.Weight && + r.Fields.IsEpic == n.Fields.IsEpic && + r.Fields.HasEpic == n.Fields.HasEpic && + + // relationships + isSameStringSlice(r.Fields.Repository, n.Fields.Repository) && + isSameStringSlice(r.Fields.Milestone, n.Fields.Milestone) && + isSameStringSlice(r.Fields.Author, n.Fields.Author) && + isSameStringSlice(r.Fields.Labels, n.Fields.Labels) && + isSameStringSlice(r.Fields.Assignees, n.Fields.Assignees) && + + true +} + +type IssueRecords []IssueRecord + +func (records IssueRecords) ByID(id string) string { + for _, record := range records { + if record.Fields.ID == id { + return record.ID + } + } + return "" +} diff --git a/repo.go b/repo.go deleted file mode 100644 index 03582627b..000000000 --- a/repo.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "fmt" - "net/url" - "strings" -) - -type Repo string - -func NewRepo(path string) Repo { - parts := strings.Split(path, "/") - if len(parts) < 3 { - return Repo(fmt.Sprintf("https://github.com/%s", path)) - } - if !strings.Contains(path, "://") { - return Repo(fmt.Sprintf("https://%s", path)) - } - return Repo(path) -} - -// FIXME: make something more reliable -func (r Repo) Provider() Provider { - if strings.Contains(string(r), "github.com") { - return GitHubProvider - } - return GitLabProvider -} - -func (r Repo) Namespace() string { - u, err := url.Parse(string(r)) - if err != nil { - return "" - } - parts := strings.Split(u.Path, "/")[1:] - return strings.Join(parts[:len(parts)-1], "/") -} - -func (r Repo) Project() string { - parts := strings.Split(string(r), "/") - return parts[len(parts)-1] -} - -func (r Repo) Canonical() string { - // FIXME: use something smarter (the shortest unique response) - return string(r) -} - -func (r Repo) SiteURL() string { - switch r.Provider() { - case GitHubProvider: - return "https://github.com" - case GitLabProvider: - u, err := url.Parse(string(r)) - if err != nil { - return "" - } - return fmt.Sprintf("%s://%s", u.Scheme, u.Host) - } - panic("should not happen") -} - -func (r Repo) RepoPath() string { - u, err := url.Parse(string(r)) - if err != nil { - panic(err) - } - return u.Path[1:] -} diff --git a/target.go b/target.go new file mode 100644 index 000000000..242757dd7 --- /dev/null +++ b/target.go @@ -0,0 +1,156 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "sort" + "strconv" + "strings" +) + +type Target string + +func ParseTargets(inputs []string) (Targets, error) { + targetMap := map[string]string{} + for _, input := range inputs { + // check if input is a local path + if _, err := os.Stat(input); err == nil { + return nil, fmt.Errorf("filesystem target are not yet supported") + } + + // parse issue + str := input + issue := "" + parts := strings.Split(str, "/issues/") + switch len(parts) { + case 1: + case 2: + str = parts[0] + issue = parts[1] + default: + return nil, fmt.Errorf("invalid target: %q", input) + } + parts = strings.Split(str, "#") + switch len(parts) { + case 1: + case 2: + str = parts[0] + issue = parts[1] + default: + return nil, fmt.Errorf("invalid target: %q", input) + } + + // parse scheme + parts = strings.Split(str, "/") + if len(parts) < 3 { + str = fmt.Sprintf("https://github.com/%s", str) + } + + if !strings.Contains(str, "://") { + str = fmt.Sprintf("https://%s", str) + } + + // append issue + if issue != "" { + _, err := strconv.Atoi(issue) + if err != nil { + return nil, fmt.Errorf("invalid target (issue): %q", input) + } + str = str + "/issues/" + issue + } + + target := string(str) + targetMap[target] = target + } + targets := []string{} + for _, target := range targetMap { + targets = append(targets, target) + } + targets = uniqueStrings(targets) + sort.Strings(targets) + + typed := Targets{} + for _, target := range targets { + typed = append(typed, Target(target)) + } + return typed, nil +} + +func (t Target) Issue() string { + parts := strings.Split(string(t), "/issues/") + switch len(parts) { + case 1: + return "" + case 2: + return parts[1] + default: + panic("invalid target") + } +} + +func (t Target) ProjectURL() string { + return strings.Split(string(t), "/issues/")[0] +} + +func (t Target) Namespace() string { + u, err := url.Parse(t.ProjectURL()) + if err != nil { + return "" + } + parts := strings.Split(u.Path, "/")[1:] + return strings.Join(parts[:len(parts)-1], "/") +} + +func (t Target) Project() string { + parts := strings.Split(t.ProjectURL(), "/") + return parts[len(parts)-1] +} + +func (t Target) Path() string { + return fmt.Sprintf("%s/%s", t.Namespace(), t.Project()) +} + +func (t Target) Canonical() string { return string(t) } + +func (t Target) Driver() ProviderDriver { + if strings.Contains(string(t), "github.com") { + return GithubDriver + } + return GitlabDriver +} + +func (t Target) ProviderURL() string { + switch t.Driver() { + case GithubDriver: + return "https://github.com" + case GitlabDriver: + u, err := url.Parse(string(t)) + if err != nil { + return "" + } + return fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + panic("should not happen") +} + +type Targets []Target + +func (t Targets) UniqueProjects() Targets { + urlMap := map[string]bool{} + for _, target := range t { + urlMap[target.ProjectURL()] = true + } + + urls := []string{} + for url := range urlMap { + urls = append(urls, url) + } + sort.Strings(urls) + + filtered := Targets{} + for _, url := range urls { + filtered = append(filtered, Target(url)) + } + return filtered +} diff --git a/target_test.go b/target_test.go new file mode 100644 index 000000000..3219f1091 --- /dev/null +++ b/target_test.go @@ -0,0 +1,57 @@ +package main + +import "fmt" + +func ExampleParseTargets() { + targets, _ := ParseTargets([]string{ + "moul/project1", + "moul/project2#42", + "github.com/moul/project3", + "https://github.com/moul/project4/issues/42", + "https://gitlab.com/moul/project5#42", + "gitlab.com/moul/project6", + }) + for _, target := range targets { + fmt.Printf( + "target=%q canonical=%q project=%q namespace=%q providerurl=%q driver=%q path=%q projecturl=%q issue=%q\n", + target, + target.Canonical(), + target.Project(), + target.Namespace(), + target.ProviderURL(), + target.Driver(), + target.Path(), + target.ProjectURL(), + target.Issue(), + ) + } + // Output: + // target="https://github.com/moul/project1" canonical="https://github.com/moul/project1" project="project1" namespace="moul" providerurl="https://github.com" driver="github" path="moul/project1" projecturl="https://github.com/moul/project1" issue="" + // target="https://github.com/moul/project2/issues/42" canonical="https://github.com/moul/project2/issues/42" project="project2" namespace="moul" providerurl="https://github.com" driver="github" path="moul/project2" projecturl="https://github.com/moul/project2" issue="42" + // target="https://github.com/moul/project3" canonical="https://github.com/moul/project3" project="project3" namespace="moul" providerurl="https://github.com" driver="github" path="moul/project3" projecturl="https://github.com/moul/project3" issue="" + // target="https://github.com/moul/project4/issues/42" canonical="https://github.com/moul/project4/issues/42" project="project4" namespace="moul" providerurl="https://github.com" driver="github" path="moul/project4" projecturl="https://github.com/moul/project4" issue="42" + // target="https://gitlab.com/moul/project5/issues/42" canonical="https://gitlab.com/moul/project5/issues/42" project="project5" namespace="moul" providerurl="https://gitlab.com" driver="gitlab" path="moul/project5" projecturl="https://gitlab.com/moul/project5" issue="42" + // target="https://gitlab.com/moul/project6" canonical="https://gitlab.com/moul/project6" project="project6" namespace="moul" providerurl="https://gitlab.com" driver="gitlab" path="moul/project6" projecturl="https://gitlab.com/moul/project6" issue="" +} + +func ExampleTargets_UniqueProjects() { + targets, _ := ParseTargets([]string{ + "moul/project1", + "moul/project2#42", + "moul/project2", + "github.com/moul/project1", + "https://github.com/moul/project2/issues/42", + "https://gitlab.com/moul/project1#42", + "https://gitlab.com/moul/project1", + "https://gitlab.com/moul/project2", + "gitlab.com/moul/project1", + }) + for _, target := range targets.UniqueProjects() { + fmt.Println(target.Canonical()) + } + // Output: + // https://github.com/moul/project1 + // https://github.com/moul/project2 + // https://gitlab.com/moul/project1 + // https://gitlab.com/moul/project2 +} diff --git a/util.go b/util.go index 84a04c261..f9dd6c595 100644 --- a/util.go +++ b/util.go @@ -2,9 +2,11 @@ package main import ( "fmt" - "os" + "reflect" "regexp" + "sort" "strings" + "time" ) func wrap(text string, lineWidth int) string { @@ -37,24 +39,6 @@ func panicIfErr(err error) { } } -func getReposFromTargets(targets []string) []string { - reposMap := map[string]bool{} - - for _, target := range targets { - if _, err := os.Stat(target); err == nil { - logger().Fatal("filesystem target are not yet supported") - } - repo := strings.Split(target, "/issues")[0] - repo = strings.Split(target, "#")[0] - reposMap[repo] = true - } - repos := []string{} - for repo := range reposMap { - repos = append(repos, repo) - } - return uniqueStrings(repos) -} - func uniqueStrings(input []string) []string { u := make([]string, 0, len(input)) m := make(map[string]bool) @@ -69,8 +53,32 @@ func uniqueStrings(input []string) []string { return u } +func normalizeURL(input string) string { + parts := strings.Split(input, "://") + output := fmt.Sprintf("%s://%s", parts[0], strings.Replace(parts[1], "//", "/", -1)) + output = strings.TrimRight(output, "#") + output = strings.TrimRight(output, "/") + return output +} + var rxDNSName = regexp.MustCompile(`^([a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62})*[\._]?$`) func isDNSName(input string) bool { return rxDNSName.MatchString(input) } + +func isSameStringSlice(a, b []string) bool { + if a == nil { + a = []string{} + } + if b == nil { + b = []string{} + } + sort.Strings(a) + sort.Strings(b) + return reflect.DeepEqual(a, b) +} + +func isSameAirtableDate(a, b time.Time) bool { + return a.Truncate(time.Millisecond).UTC() == b.Truncate(time.Millisecond).UTC() +}