diff --git a/cmd_airtable.go b/cmd_airtable.go new file mode 100644 index 000000000..7a5666edf --- /dev/null +++ b/cmd_airtable.go @@ -0,0 +1,147 @@ +package main + +import ( + "encoding/json" + "time" + + airtable "github.com/fabioberger/airtable-go" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +type airtableOptions struct { + AirtableTableName string `mapstructure:"airtable-table-name"` + AirtableBaseID string `mapstructure:"airtable-base-id"` + AirtableToken string `mapstructure:"airtable-token"` + Targets []string +} + +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") + viper.BindPFlags(flags) +} + +func newAirtableCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "airtable", + } + cmd.AddCommand(newAirtableSyncCommand()) + return cmd +} + +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.Targets = args + return airtableSync(opts) + }, + } + airtableSetupFlags(cmd.Flags(), opts) + return cmd +} + +func airtableSync(opts *airtableOptions) error { + issues, err := loadIssues(db, nil) + if err != nil { + return errors.Wrap(err, "failed to load issues") + } + if err := issues.prepare(); err != nil { + return errors.Wrap(err, "failed to prepare issues") + } + issues.filterByTargets(opts.Targets) + logger().Debug("fetch db entries", zap.Int("count", len(issues))) + + at, err := airtable.New(opts.AirtableToken, opts.AirtableBaseID) + if err != nil { + return err + } + + alreadyInAirtable := map[string]bool{} + + records := []airtableRecord{} + if err := at.ListRecords(opts.AirtableTableName, &records); err != nil { + return err + } + logger().Debug("fetched airtable records", zap.Int("count", len(records))) + + for _, record := range records { + alreadyInAirtable[record.Fields.ID] = true + if issue, found := issues[record.Fields.ID]; !found { + logger().Debug("destroying airtable record", zap.String("ID", record.Fields.ID)) + if err := at.DestroyRecord(opts.AirtableTableName, record.ID); err != nil { + return errors.Wrap(err, "failed to destroy record") + } + } else { + if issue.Hidden { + continue + } + // FIXME: check if entry changed before updating + logger().Debug("updating airtable record", zap.String("ID", issue.URL)) + if err := at.UpdateRecord(opts.AirtableTableName, record.ID, issue.ToAirtableRecord().Fields.Map(), &record); err != nil { + return errors.Wrap(err, "failed to update record") + } + } + } + + for _, issue := range issues { + if issue.Hidden { + continue + } + if _, found := alreadyInAirtable[issue.URL]; found { + continue + } + logger().Debug("creating airtable record", zap.String("ID", issue.URL)) + if err := at.CreateRecord(opts.AirtableTableName, issue.ToAirtableRecord()); err != nil { + return err + } + } + return nil +} + +func (i Issue) ToAirtableRecord() airtableRecord { + return airtableRecord{ + ID: "", + Fields: airtableIssue{ + ID: i.URL, + Created: i.CreatedAt, + Updated: i.UpdatedAt, + Title: i.Title, + }, + } +} + +type airtableRecord struct { + ID string `json:"id,omitempty"` + Fields airtableIssue `json:"fields,omitempty"` +} + +type airtableIssue struct { + ID string + Created time.Time + Updated time.Time + Title string +} + +func (a airtableIssue) Map() map[string]interface{} { + return map[string]interface{}{ + "ID": a.ID, + "Created": a.Created, + "Updated": a.Updated, + "Title": a.Title, + } +} diff --git a/go.mod b/go.mod index 77b2a72bd..dee72222b 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/awalterschulze/gographviz v0.0.0-20180927133620-e69668a01397 github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 // indirect github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect + github.com/fabioberger/airtable-go v3.1.0+incompatible // indirect github.com/go-chi/chi v3.3.3+incompatible github.com/go-chi/docgen v1.0.2 // indirect github.com/go-chi/render v1.0.1 diff --git a/go.sum b/go.sum index 32e6b5045..1f00db3c5 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 h1:BZGp1dbKF github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6/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= +github.com/fabioberger/airtable-go v3.1.0+incompatible h1:n5dw+HWBc+hytrVL75xe94EGt7FtNFGDII1tNoWTCAE= +github.com/fabioberger/airtable-go v3.1.0+incompatible/go.mod h1:EoKuSh7EefzhMCyVr6iXPlgFzDgHyZCZ3E5Sg8Cy9GM= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-chi/chi v3.3.3+incompatible h1:KHkmBEMNkwKuK4FdQL7N2wOeB9jnIx7jR5wsuSBEFI8= diff --git a/main.go b/main.go index 3f1d90563..abbbdedcc 100644 --- a/main.go +++ b/main.go @@ -96,6 +96,7 @@ func newRootCommand() *cobra.Command { newRunCommand(), newDBCommand(), newWebCommand(), + newAirtableCommand(), ) viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))