diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fcd314 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +### Go Patch ### +/vendor/ +/Godeps/ + +stars diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..c7f9ee0 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,23 @@ +# This is an example goreleaser.yaml file with some sane defaults. +# Make sure to check the documentation at http://goreleaser.com +builds: +- env: + - GO111MODULE=on + - CGO_ENABLED=0 +archive: + replacements: + darwin: Darwin + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6ee1823 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 George Kontridze + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c56e956 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# stars + +A command-line interface to your GitHub stars + +## Development + +```bash +git clone git@github.com:gkze/stars-go.git +cd stars-go +go build # need Golang 1.11+ +``` + +## Installation + +Binaries are available on the releases page. You can also install it easily with: + +```bash +go get -u github.com/gkze/stars +``` + +You also need a `~/.netrc` with a personal access token configured: + +```bash +$ cat ~/.netrc +machine api.github.com + login gkze + password [your github token here] +``` + +Usage: + +```bash +$ stars +NAME: + stars - Command-line interface to YOUR GitHub stars + +USAGE: + stars [global options] command [command options] [arguments...] + +VERSION: + 0.0.0 + +COMMANDS: + save Save all stars + list-topics list all topics of starred projects + random Browse random stars + clear Clear local stars cache + cleanup Clean up old stars + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --help, -h show help + --version, -v print the version +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..35c78e4 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/gkze/stars + +require ( + github.com/DataDog/zstd v1.3.4 // indirect + github.com/Sereal/Sereal v0.0.0-20180905114147-563b78806e28 // indirect + github.com/asdine/storm v2.1.2+incompatible + github.com/boltdb/bolt v1.3.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dickeyxxx/netrc v0.0.0-20180207092346-e1a19c977509 // indirect + github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect + github.com/google/go-github v17.0.0+incompatible + github.com/jdxcode/netrc v0.0.0-20180207092346-e1a19c977509 + github.com/kr/pretty v0.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.2.2 // indirect + github.com/urfave/cli v1.20.0 + github.com/vmihailenco/msgpack v4.0.0+incompatible // indirect + go.etcd.io/bbolt v1.3.0 // indirect + golang.org/x/net v0.0.0-20180921000356-2f5d2388922f // indirect + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be + golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f // indirect + golang.org/x/sys v0.0.0-20180921163948-d47a0f339242 // indirect + google.golang.org/appengine v1.2.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect +) + +replace github.com/google/go-github => github.com/google/go-github/v18 v18.1.1-0.20180920013327-07716bad7a0c diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a031791 --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/DataDog/zstd v1.3.4 h1:LAGHkXuvC6yky+C2CUG2tD7w8QlrUwpue8XwIh0X4AY= +github.com/DataDog/zstd v1.3.4/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Sereal/Sereal v0.0.0-20180905114147-563b78806e28 h1:KjLSBawWQq6I0p9VRX8RtHIuttTYvUCGfMgNoBBFxYs= +github.com/Sereal/Sereal v0.0.0-20180905114147-563b78806e28/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= +github.com/asdine/storm v2.1.2+incompatible h1:dczuIkyqwY2LrtXPz8ixMrU/OFgZp71kbKTHGrXYt/Q= +github.com/asdine/storm v2.1.2+incompatible/go.mod h1:RarYDc9hq1UPLImuiXK3BIWPJLdIygvV3PsInK0FbVQ= +github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +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/dickeyxxx/netrc v0.0.0-20180207092346-e1a19c977509 h1:WF2KfMRjy3IQj8L2rlhqJC3RysGz92LuzbgjqdkS2WI= +github.com/dickeyxxx/netrc v0.0.0-20180207092346-e1a19c977509/go.mod h1:yJi2ErNJXXF67mkADCp1kk8AMBFiX48CwUWnsjpCpII= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-github/v18 v18.1.1-0.20180920013327-07716bad7a0c h1:p+V16Anf7PTAUHoIizdalt5kFArNheY09sZH3Fr4gK4= +github.com/google/go-github/v18 v18.1.1-0.20180920013327-07716bad7a0c/go.mod h1:Bf4Ut1RTeH0WuX7Z4Zf7N+qp/YqgcFOxvTLuSO+aY/k= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/jdxcode/netrc v0.0.0-20180207092346-e1a19c977509 h1:G91xmBQ9Jntm4Dqgb0BGXMRipGBiYLYNWeKcbeJTXPk= +github.com/jdxcode/netrc v0.0.0-20180207092346-e1a19c977509/go.mod h1:PSWm5RA4GUQ+cyCXiBIIUjlDWdJci5cU3GVKwaQRmW8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/vmihailenco/msgpack v4.0.0+incompatible h1:R/ftCULcY/r0SLpalySUSd8QV4fVABi/h0D/IjlYJzg= +github.com/vmihailenco/msgpack v4.0.0+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +go.etcd.io/bbolt v1.3.0 h1:oY10fI923Q5pVCVt1GBTZMn8LHo5M+RCInFpeMnV4QI= +go.etcd.io/bbolt v1.3.0/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180921000356-2f5d2388922f h1:QM2QVxvDoW9PFSPp/zy9FgxJLfaWTZlS61KEPtBwacM= +golang.org/x/net v0.0.0-20180921000356-2f5d2388922f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180824143301-4910a1d54f87/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180921163948-d47a0f339242 h1:5DYsa+ZAwcJHjuY0Qet390sUr7qwkpnRsUNjddyc0b8= +golang.org/x/sys v0.0.0-20180921163948-d47a0f339242/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0 h1:S0iUepdCWODXRvtE+gcRDd15L+k+k1AiHlMiMjefH24= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ca89b91 --- /dev/null +++ b/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "sync" + + "github.com/gkze/stars/starmanager" + "github.com/urfave/cli" +) + +func main() { + sm, err := starmanager.New() + if err != nil { + log.Printf("Error creating StarManager! %v", err.Error()) + } + + cmdline := cli.NewApp() + cmdline.Name = "stars" + cmdline.Usage = "Command-line interface to YOUR GitHub stars" + cmdline.Commands = []cli.Command{ + { + Name: "save", + Usage: "Save all stars", + Action: func(c *cli.Context) error { + sm.SaveAllStars() + + return nil + }, + }, + { + Name: "list-topics", + Usage: "list all topics of starred projects", + Action: func(c *cli.Context) error { + sm.SaveIfEmpty() + for _, t := range sm.GetTopics() { + fmt.Println(t) + } + + return nil + }, + }, + { + Name: "random", + Usage: "Browse random stars", + Flags: []cli.Flag{ + cli.IntFlag{ + Name: "count, c", + Value: 6, + Usage: "Number of random stars to browse", + }, + cli.StringFlag{ + Name: "language, l", + Usage: "Limit to projects written only in this language", + }, + cli.StringFlag{ + Name: "topic, t", + Usage: "Limit to projects with this topic", + }, + }, + Action: func(c *cli.Context) error { + sm.SaveIfEmpty() + stars, err := sm.GetRandomProjects(c.Int("count"), c.String("languaage"), c.String("topic")) + if err != nil { + log.Printf(err.Error()) + } + + wg := sync.WaitGroup{} + for _, proj := range stars { + wg.Add(1) + go func(p starmanager.Star) { + defer wg.Done() + cmd := exec.Command("/usr/bin/open", p.URL) + err := cmd.Run() + + if err != nil { + panic(err) + } + }(proj) + } + wg.Wait() + + return nil + }, + }, + { + Name: "clear", + Usage: "Clear local stars cache", + Action: func(c *cli.Context) error { + if err := sm.ClearCache(); err != nil { + return err + } + + return nil + }, + }, + { + Name: "cleanup", + Usage: "Clean up old stars", + Flags: []cli.Flag{ + cli.IntFlag{ + Name: "months, m", + Value: 2, + Usage: "Number of months to delete projects older than", + }, + }, + Action: func(c *cli.Context) error { + sm.SaveIfEmpty() + if err := sm.RemoveOlderThan(c.Int("months")); err != nil { + return err + } + + return nil + }, + }, + } + + cmdline.Run(os.Args) +} diff --git a/starmanager/starmanager.go b/starmanager/starmanager.go new file mode 100644 index 0000000..f363de8 --- /dev/null +++ b/starmanager/starmanager.go @@ -0,0 +1,317 @@ +package starmanager + +import ( + "context" + "log" + "net/url" + "os" + "os/user" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "math/rand" + + "golang.org/x/oauth2" + + "github.com/asdine/storm" + "github.com/asdine/storm/q" + "github.com/gkze/stars/utils" + "github.com/google/go-github/github" +) + +// GITHUB - the GitHub API host +const GITHUB string = "api.github.com" + +// PAGESIZE - the default response page size (GitHub maximum is 100 so we use that) +const PAGESIZE int = 100 + +// Star represents the starred project that is saved locally +type Star struct { + PushedAt time.Time `storm:"index"` + URL string `storm:"id,index,unique"` + Language string `storm:"index"` + Stargazers int + Description string `storm:"index"` + Topics []string `storm:"index"` +} + +// StarManager - the main object that manages a GitHub user's stars +type StarManager struct { + Username string + Password string + Context context.Context + Client *github.Client + DB *storm.DB +} + +// New - initialize a new starmanager +func New() (*StarManager, error) { + username, password, err := utils.GetNetrcAuth(GITHUB) + if err != nil { + return nil, err + } + ctx := context.Background() + client := github.NewClient(oauth2.NewClient( + ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: password}), + )) + + currentUser, err := user.Current() + if err != nil { + log.Printf("Could not determine the current user! %v", err.Error()) + + return nil, err + } + + db, err := storm.Open(filepath.Join(currentUser.HomeDir, ".cache/stars.db"), storm.Batch()) + if err != nil { + log.Printf("An error occurred opening the db! %v", err.Error()) + + return nil, err + } + + return &StarManager{ + Username: username, + Password: password, + Context: ctx, + Client: client, + DB: db, + }, nil +} + +// ClearCache resets the local db. +func (s *StarManager) ClearCache() error { + if err := os.Remove(s.DB.Bolt.Path()); err != nil { + return err + } + + log.Printf("Cleared cache") + return nil +} + +// SaveStarredRepository saves a single starred project to the local cache. +func (s *StarManager) SaveStarredRepository(repo *github.Repository, wg *sync.WaitGroup) error { + wg.Add(1) + defer wg.Done() + lang, desc := "", "" + + // We have to perform the below two checks because some repos don't have languages or + // desciptions, and the client does not create those struct fields, resulting in a SIGSEGV + // (segmentation fault). + if repo.Language != nil { + lang = *repo.Language + } + + if repo.Description != nil { + desc = *repo.Description + } + + err := s.DB.Save(&Star{ + PushedAt: repo.PushedAt.Time, + URL: *repo.HTMLURL, + Language: strings.ToLower(lang), + Stargazers: *repo.StargazersCount, + Description: desc, + Topics: repo.Topics, + }) + if err != nil { + return err + } + + log.Printf("Saved %s (with topics %s)\n", *repo.HTMLURL, repo.Topics) + return nil +} + +// SaveStarredPage saves an entire page of starred repositories concurrently, optionally sending +// server responses to a channel if it is provided. +func (s *StarManager) SaveStarredPage(pageno int, responses chan *github.Response, wg *sync.WaitGroup) chan error { + wg.Add(1) + defer wg.Done() + errors := make(chan error) + + firstPage, response, err := s.Client.Activity.ListStarred( + s.Context, + s.Username, + &github.ActivityListStarredOptions{ + ListOptions: github.ListOptions{ + PerPage: PAGESIZE, + Page: pageno, + }, + }, + ) + if err != nil { + log.Printf( + "An error occurred while attempting to fetch page %d of %s's GitHub stars!", + pageno, + s.Username, + ) + + errors <- err + } + + if responses != nil { + responses <- response + } + + log.Printf("Attempting to save starred projects on page %d...\n", pageno) + for _, r := range firstPage { + go s.SaveStarredRepository(r.Repository, wg) + } + + return errors +} + +// SaveAllStars saves all stars. +func (s *StarManager) SaveAllStars() (bool, error) { + wg := sync.WaitGroup{} + responses := make(chan *github.Response, 1) + + // Fetch the first page to determine the last page number from the response "Link" header + log.Printf("Attempting to save first page...") + go s.SaveStarredPage(1, responses, &wg) + firstPageResponse := <-responses + + log.Printf("Attempting to save the rest of the pages...") + for i := 2; i <= firstPageResponse.LastPage; i++ { + go s.SaveStarredPage(i, nil, &wg) + } + wg.Wait() + + log.Printf("Successfully saved all starred projects") + return true, nil +} + +// SaveIfEmpty saves all stars if the local cache is empty +func (s *StarManager) SaveIfEmpty() { + if count, _ := s.DB.Count(&Star{}); count == 0 { + s.SaveAllStars() + } +} + +// GetTopics returns topics for a repository, otherwise if no repository is passed, returns +// a list of all topics +func (s *StarManager) GetTopics() []string { + stars := []Star{} + allTopics := []string{} + uniqueTopics := []string{} + keys := map[string]bool{} + + s.DB.All(&stars) + + for _, star := range stars { + allTopics = append(allTopics, star.Topics...) + } + + for _, topic := range allTopics { + if _, value := keys[topic]; !value { + keys[topic] = true + uniqueTopics = append(uniqueTopics, topic) + } + } + + sort.Slice(uniqueTopics, func(i, j int) bool { + return uniqueTopics[i] < uniqueTopics[j] + }) + + return uniqueTopics +} + +// GetRandomProjects returns random projects given a project count to return, and an optional +// language and topic to filter by. +func (s *StarManager) GetRandomProjects(count int, language, topic string) ([]Star, error) { + stars := []Star{} + + if language != "" { + if err := s.DB.Select(q.Eq("Language", language)).Find(&stars); err != nil { + return nil, err + } + } else { + if err := s.DB.All(&stars); err != nil { + return nil, err + } + } + + if topic != "" { + topicStars := []Star{} + + for _, star := range stars { + if utils.StringInSlice(topic, star.Topics) { + topicStars = append(topicStars, star) + } + } + + stars = topicStars + } + + rand.Seed(time.Now().UTC().UnixNano()) + rand.Shuffle(len(stars), func(i, j int) { + stars[i], stars[j] = stars[j], stars[i] + }) + + return stars[0:count], nil +} + +// RemoveStar unstars the project on Github and removes the star from the local cache. +func (s *StarManager) RemoveStar(star *Star, wg *sync.WaitGroup) (bool, error) { + wg.Add(1) + defer wg.Done() + + starURL, parseErr := url.Parse(star.URL) + if parseErr != nil { + return false, parseErr + } + + splitPath := strings.Split(starURL.Path, "/") + + _, unstarErr := s.Client.Activity.Unstar(s.Context, splitPath[1], splitPath[2]) + if unstarErr != nil { + log.Printf("An error occurred while attempting to unstar %s: %s\n", star.URL, unstarErr.Error()) + return false, unstarErr + } + + deleteErr := s.DB.DeleteStruct(star) + if deleteErr != nil { + return false, deleteErr + } + + log.Printf("Removed %s", star.URL) + + return true, nil +} + +// RemoveOlderThan removes stars older than a specified time +func (s *StarManager) RemoveOlderThan(months int) error { + allStars := []*Star{} + toDelete := make(chan *Star) + wg := sync.WaitGroup{} + then := time.Now().AddDate(0, -months, 0) + + if err := s.DB.All(&allStars); err != nil { + return err + } + + log.Printf("Filtering stars to delete (from %d)...", len(allStars)) + for _, star := range allStars { + if star.PushedAt.Before(then) { + log.Printf("Queueing %s for deletion (last pushed at %+v)", star.URL, star.PushedAt) + + go func(ch chan *Star, s *Star, wg *sync.WaitGroup) { + wg.Add(1) + defer wg.Done() + + ch <- s + }(toDelete, star, &wg) + } + } + + // Cannot close channel in main goroutine as it will block + go func() { close(toDelete) }() + + for star := range toDelete { + go s.RemoveStar(star, &wg) + } + wg.Wait() + + return nil +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..cdf76bd --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,44 @@ +package utils + +import ( + "os" + "os/user" + "path/filepath" + + "github.com/jdxcode/netrc" +) + +// GetNetrcAuth - Returns the username and password (in this case the API token) for a given host +// configured in .netrc in the user's home directory. +func GetNetrcAuth(hostname string) (string, string, error) { + usr, err := user.Current() + if err != nil { + return "", "", err + } + + netrcPath := filepath.Join(usr.HomeDir, ".netrc") + + if _, err := os.Stat(netrcPath); os.IsNotExist(err) { + return "", "", err + } + + n, err := netrc.Parse(filepath.Join(usr.HomeDir, ".netrc")) + if err != nil { + return "", "", err + } + + auth := n.Machine(hostname) + + return auth.Get("login"), auth.Get("password"), nil +} + +// StringInSlice checks whether a given string is in a slice +func StringInSlice(s string, sl []string) bool { + for _, c := range sl { + if c == s { + return true + } + } + + return false +}