Skip to content

Commit aac0c6d

Browse files
author
nate smith
committed
hackday: very WIP approach to frecency stuff
1 parent 762d956 commit aac0c6d

File tree

5 files changed

+298
-4
lines changed

5 files changed

+298
-4
lines changed

pkg/cmd/issue/close/close.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
3232
cmd := &cobra.Command{
3333
Use: "close {<number> | <url>}",
3434
Short: "Close issue",
35-
Args: cobra.ExactArgs(1),
35+
Args: cobra.MaximumNArgs(1),
3636
RunE: func(cmd *cobra.Command, args []string) error {
3737
// support `-R, --repo` override
3838
opts.BaseRepo = f.BaseRepo
@@ -58,6 +58,15 @@ func closeRun(opts *CloseOptions) error {
5858
if err != nil {
5959
return err
6060
}
61+
if opts.IO.CanPrompt() && opts.SelectorArg == "" {
62+
baseRepo, err := opts.BaseRepo()
63+
issueNumber, err := shared.SelectFrecent(httpClient, baseRepo)
64+
if err != nil {
65+
return err
66+
}
67+
opts.SelectorArg = issueNumber
68+
}
69+
6170
apiClient := api.NewClientFromHTTP(httpClient)
6271

6372
issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
@@ -70,6 +79,12 @@ func closeRun(opts *CloseOptions) error {
7079
return nil
7180
}
7281

82+
err = shared.UpdateFrecent(issue.Number)
83+
if err != nil {
84+
// TODO just warn or ignore or whatever
85+
return err
86+
}
87+
7388
err = api.IssueClose(apiClient, baseRepo, *issue)
7489
if err != nil {
7590
return err

pkg/cmd/issue/delete/delete.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
3535
cmd := &cobra.Command{
3636
Use: "delete {<number> | <url>}",
3737
Short: "Delete issue",
38-
Args: cobra.ExactArgs(1),
38+
Args: cobra.MaximumNArgs(1),
3939
RunE: func(cmd *cobra.Command, args []string) error {
4040
// support `-R, --repo` override
4141
opts.BaseRepo = f.BaseRepo
@@ -62,12 +62,26 @@ func deleteRun(opts *DeleteOptions) error {
6262
return err
6363
}
6464
apiClient := api.NewClientFromHTTP(httpClient)
65+
if opts.IO.CanPrompt() && opts.SelectorArg == "" {
66+
baseRepo, err := opts.BaseRepo()
67+
issueNumber, err := shared.SelectFrecent(httpClient, baseRepo)
68+
if err != nil {
69+
return err
70+
}
71+
opts.SelectorArg = issueNumber
72+
}
6573

6674
issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
6775
if err != nil {
6876
return err
6977
}
7078

79+
err = shared.UpdateFrecent(issue.Number)
80+
if err != nil {
81+
// TODO just warn or ignore or whatever
82+
return err
83+
}
84+
7185
// When executed in an interactive shell, require confirmation. Otherwise skip confirmation.
7286
if opts.IO.CanPrompt() {
7387
answer := ""

pkg/cmd/issue/edit/edit.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
5454
$ gh issue edit 23 --milestone "Version 1"
5555
$ gh issue edit 23 --body-file body.txt
5656
`),
57-
Args: cobra.ExactArgs(1),
57+
Args: cobra.MaximumNArgs(1),
5858
RunE: func(cmd *cobra.Command, args []string) error {
5959
// support `-R, --repo` override
6060
opts.BaseRepo = f.BaseRepo
@@ -136,11 +136,24 @@ func editRun(opts *EditOptions) error {
136136
return err
137137
}
138138
apiClient := api.NewClientFromHTTP(httpClient)
139+
if opts.IO.CanPrompt() && opts.SelectorArg == "" {
140+
baseRepo, err := opts.BaseRepo()
141+
issueNumber, err := shared.SelectFrecent(httpClient, baseRepo)
142+
if err != nil {
143+
return err
144+
}
145+
opts.SelectorArg = issueNumber
146+
}
139147

140148
issue, repo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
141149
if err != nil {
142150
return err
143151
}
152+
err = shared.UpdateFrecent(issue.Number)
153+
if err != nil {
154+
// TODO just warn or ignore or whatever
155+
return err
156+
}
144157

145158
editable := opts.Editable
146159
editable.Title.Default = issue.Title

pkg/cmd/issue/shared/frecent.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package shared
2+
3+
import (
4+
"fmt"
5+
"io/ioutil"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"time"
11+
12+
"github.com/AlecAivazis/survey/v2"
13+
"github.com/cli/cli/api"
14+
"github.com/cli/cli/internal/config"
15+
"github.com/cli/cli/internal/ghrepo"
16+
"github.com/cli/cli/pkg/prompt"
17+
"gopkg.in/yaml.v3"
18+
)
19+
20+
// TODO scope stats by repo
21+
// TODO generalize this
22+
// TODO scope stats by data type
23+
// TODO should work with:
24+
// - issues
25+
// - prs
26+
// - gists
27+
// - repos
28+
// - (?) releases
29+
// - (?) runs
30+
// - (?) workflows
31+
// - (?) ssh key
32+
// - (?) extensions
33+
34+
type ByLastAccess []issueWithStats
35+
36+
func (l ByLastAccess) Len() int {
37+
return len(l)
38+
}
39+
func (l ByLastAccess) Swap(i, j int) {
40+
l[i], l[j] = l[j], l[i]
41+
}
42+
func (l ByLastAccess) Less(i, j int) bool {
43+
return l[i].Last.After(l[j].Last)
44+
}
45+
46+
type ByFrecency []issueWithStats
47+
48+
func (f ByFrecency) Len() int {
49+
return len(f)
50+
}
51+
func (f ByFrecency) Swap(i, j int) {
52+
f[i], f[j] = f[j], f[i]
53+
}
54+
func (f ByFrecency) Less(i, j int) bool {
55+
iScore := f[i].CountEntry.Score()
56+
jScore := f[j].CountEntry.Score()
57+
if iScore == jScore {
58+
return f[i].Last.After(f[j].Last)
59+
}
60+
return iScore > jScore
61+
}
62+
63+
type issueWithStats struct {
64+
api.Issue
65+
CountEntry
66+
}
67+
68+
func sortByFrecent(issues []api.Issue, frecent map[int]*CountEntry) []string {
69+
withStats := []issueWithStats{}
70+
for _, i := range issues {
71+
entry, ok := frecent[i.Number]
72+
if !ok {
73+
entry = &CountEntry{}
74+
}
75+
withStats = append(withStats, issueWithStats{
76+
Issue: i,
77+
CountEntry: *entry,
78+
})
79+
}
80+
sort.Sort(ByLastAccess(withStats))
81+
previousIssue := withStats[0]
82+
withStats = withStats[1:]
83+
sort.Stable(ByFrecency(withStats))
84+
choices := []string{fmt.Sprintf("%d", previousIssue.Number)}
85+
for _, ws := range withStats {
86+
choices = append(choices, fmt.Sprintf("%d", ws.Number))
87+
}
88+
return choices
89+
}
90+
91+
func SelectFrecent(c *http.Client, repo ghrepo.Interface) (string, error) {
92+
client := api.NewCachedClient(c, time.Hour*6)
93+
94+
issues, err := getIssues(client, repo)
95+
if err != nil {
96+
return "", err
97+
}
98+
99+
frecent, err := getFrecentEntry(defaultFrecentPath())
100+
if err != nil {
101+
return "", err
102+
}
103+
104+
choices := sortByFrecent(issues, frecent.Issues)
105+
106+
choice := ""
107+
err = prompt.SurveyAskOne(&survey.Select{
108+
Message: "Which issue?",
109+
Options: choices,
110+
}, &choice)
111+
if err != nil {
112+
return "", err
113+
}
114+
115+
return choice, nil
116+
}
117+
118+
type CountEntry struct {
119+
Last time.Time
120+
Count int
121+
}
122+
123+
func (c CountEntry) Score() int {
124+
if c.Count == 0 {
125+
return 0
126+
}
127+
duration := time.Since(c.Last)
128+
recencyScore := 10
129+
if duration < 1*time.Hour {
130+
recencyScore = 100
131+
} else if duration < 6*time.Hour {
132+
recencyScore = 80
133+
} else if duration < 24*time.Hour {
134+
recencyScore = 60
135+
} else if duration < 3*24*time.Hour {
136+
recencyScore = 40
137+
} else if duration < 7*24*time.Hour {
138+
recencyScore = 20
139+
}
140+
141+
return c.Count * recencyScore
142+
}
143+
144+
type FrecentEntry struct {
145+
Issues map[int]*CountEntry
146+
}
147+
148+
func defaultFrecentPath() string {
149+
return filepath.Join(config.StateDir(), "frecent.yml")
150+
}
151+
152+
func getFrecentEntry(stateFilePath string) (*FrecentEntry, error) {
153+
content, err := ioutil.ReadFile(stateFilePath)
154+
if err != nil {
155+
return nil, err
156+
}
157+
158+
var stateEntry FrecentEntry
159+
err = yaml.Unmarshal(content, &stateEntry)
160+
if err != nil {
161+
return nil, err
162+
}
163+
164+
return &stateEntry, nil
165+
}
166+
167+
func UpdateFrecent(issueNumber int) error {
168+
frecentPath := defaultFrecentPath()
169+
frecent, err := getFrecentEntry(frecentPath)
170+
if err != nil {
171+
return err
172+
}
173+
count, ok := frecent.Issues[issueNumber]
174+
if !ok {
175+
count = &CountEntry{}
176+
frecent.Issues[issueNumber] = count
177+
}
178+
count.Count++
179+
count.Last = time.Now()
180+
content, err := yaml.Marshal(frecent)
181+
if err != nil {
182+
return err
183+
}
184+
185+
err = os.MkdirAll(filepath.Dir(frecentPath), 0755)
186+
if err != nil {
187+
return err
188+
}
189+
190+
return ioutil.WriteFile(frecentPath, content, 0600)
191+
}
192+
193+
func getIssues(c *http.Client, repo ghrepo.Interface) ([]api.Issue, error) {
194+
apiClient := api.NewClientFromHTTP(c)
195+
query := `query GetIssueNumbers($owner: String!, $repo: String!) {
196+
repository(owner: $owner, name: $repo) {
197+
issues(first:100, orderBy: {field: UPDATED_AT, direction: DESC}, states: [OPEN]) {
198+
nodes {
199+
number
200+
}
201+
}
202+
}
203+
}`
204+
variables := map[string]interface{}{
205+
"owner": repo.RepoOwner(),
206+
"repo": repo.RepoName(),
207+
}
208+
type responseData struct {
209+
Repository struct {
210+
Issues struct {
211+
Nodes []api.Issue
212+
}
213+
}
214+
}
215+
var resp responseData
216+
err := apiClient.GraphQL(repo.RepoHost(), query, variables, &resp)
217+
if err != nil {
218+
return nil, err
219+
}
220+
221+
return resp.Repository.Issues.Nodes, nil
222+
}
223+
224+
/*
225+
issues:
226+
6667:
227+
last: 10s
228+
count: 1
229+
4567:
230+
last: 10m
231+
count: 15
232+
7890:
233+
last: 5m
234+
count: 1
235+
3456:
236+
last: 40d
237+
count: 30
238+
*/

pkg/cmd/issue/view/view.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
5454
5555
With '--web', open the issue in a web browser instead.
5656
`),
57-
Args: cobra.ExactArgs(1),
57+
Args: cobra.MaximumNArgs(1),
5858
RunE: func(cmd *cobra.Command, args []string) error {
5959
// support `-R, --repo` override
6060
opts.BaseRepo = f.BaseRepo
@@ -82,6 +82,14 @@ func viewRun(opts *ViewOptions) error {
8282
if err != nil {
8383
return err
8484
}
85+
if opts.IO.CanPrompt() && opts.SelectorArg == "" {
86+
baseRepo, err := opts.BaseRepo()
87+
issueNumber, err := shared.SelectFrecent(httpClient, baseRepo)
88+
if err != nil {
89+
return err
90+
}
91+
opts.SelectorArg = issueNumber
92+
}
8593

8694
loadComments := opts.Comments
8795
if !loadComments && opts.Exporter != nil {
@@ -97,6 +105,12 @@ func viewRun(opts *ViewOptions) error {
97105
return err
98106
}
99107

108+
err = shared.UpdateFrecent(issue.Number)
109+
if err != nil {
110+
// TODO just warn or ignore or whatever
111+
return err
112+
}
113+
100114
if opts.WebMode {
101115
openURL := issue.URL
102116
if opts.IO.IsStdoutTTY() {

0 commit comments

Comments
 (0)