Skip to content

Commit ecd4ace

Browse files
committed
add improvements
1 parent cca8db6 commit ecd4ace

File tree

4 files changed

+328
-78
lines changed

4 files changed

+328
-78
lines changed

‎README.md‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
`ghtop` provides a number of views of all current public activity from all users across the entire GitHub platform. (Note that GitHub delays all events by five minutes.)
66

7-
<img width="850" src="https://user-images.githubusercontent.com/56260/101270865-3f033780-3732-11eb-8dcc-97caf7cc58e6.png" style="max-width: 850px">
7+
<img width="650" src="https://user-images.githubusercontent.com/1483922/105071141-0c3ea580-5a39-11eb-8808-34952c0bf26d.gif" style="max-width: 650px">
88

99
## Install
1010

@@ -14,26 +14,27 @@ Either `pip install ghtop` or `conda install -c fastai ghtop`.
1414

1515
Run `ghtop -h` to view the help:
1616

17-
```bash
17+
```
1818
$ ghtop -h
19-
usage: ghtop [-h] [--include_bots] [--types TYPES] [--filt {user,repo,org}] [--filtval FILTVAL]
20-
{tail,quad,users,simple}
19+
usage: ghtop [-h] [--include_bots] [--types TYPES] [--pause PAUSE] [--filt {users,repo,org}] [--filtval FILTVAL] {tail,quad,users,simple}
2120
2221
positional arguments:
2322
{tail,quad,users,simple} Operation mode to run
2423
2524
optional arguments:
2625
-h, --help show this help message and exit
27-
--include_bots Include bots (there is a lot of them!) (default: False)
28-
--types TYPES Comma-separated types of event to include (e.g PushEvent)
29-
--filt {user,repo,org} Filtering method
26+
--include_bots Include bots (there's a lot of them!) (default: False)
27+
--types TYPES Comma-separated types of event to include (e.g PushEvent) (default: )
28+
--pause PAUSE Number of seconds to pause between requests to the GitHub api (default: 0.4)
29+
--filt {users,repo,org} Filtering method
3030
--filtval FILTVAL Value to filter by (for `repo` use format `owner/repo`)
3131
```
3232

3333
There are 4 views you can choose: `ghtop simple`, `ghtop tail`, `ghtop quad`, or `ghtop users`. Each are shown and described below. All views have the following options:
3434

3535
- `--include_bots`: By default events that appear to be from bots are excluded. Add this flag to include them
3636
- `--types TYPES`: Optional comma-separated list of event types to include (defaults to all types). For a full list of types, see the GitHub [event types docs](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/github-event-types)
37+
- `--pause PAUSE`: Number of seconds to pause between requests to the GitHub api (default: 0.4). It is helpful to adjust this number if you want to get events more or less frequently. For example, if you are filtering all events by an org, then you likely want to pause for a longer period of time than the default.
3738
- `--filt` and `--filtval`: Optionally filter events to just those from one of: `user`, `repo`, or `org`, depending on `filt`. `filtval` is the value to filter by. See the [GitHub docs](https://docs.github.com/en/free-pro-team@latest/rest/reference/activity#list-public-events) for details on the public event API calls used.
3839

3940
**Important note**: while running, `ghtop` will make about 5 API calls per second. GitHub has a quota of 5000 calls per hour. When there are 1000 calls left, `ghtop` will show a warning on every call.
@@ -42,25 +43,25 @@ There are 4 views you can choose: `ghtop simple`, `ghtop tail`, `ghtop quad`, or
4243

4344
A simple dump to your console of all events as they happen.
4445

45-
<img src="https://user-images.githubusercontent.com/346999/101861674-79e7df80-3b25-11eb-92d3-f888843f4aa2.png" width="500" style="max-width: 500px">
46+
<img src="https://user-images.githubusercontent.com/1483922/105074536-56298a80-5a3d-11eb-8e8c-32ba33e09405.png" width="650" style="max-width: 650px">
4647

4748
### ghtop tail
4849

49-
Like `simple`, but removes most bots, and only includes releases, issues and PRs (open, close, and comment events). A summary of the frequency of push events is also shown at the bottom of the screen.
50+
Like `simple`, but only includes releases, issues and PRs (open, close, and comment events). A summary of the frequency of different kind of events along with sparklines are shown at the top of the screen.
5051

51-
<img src="https://user-images.githubusercontent.com/346999/101861658-69376980-3b25-11eb-96ef-9d68f075abf7.png" width="700" style="max-width: 700px">
52+
<img src="https://user-images.githubusercontent.com/1483922/105074448-398d5280-5a3d-11eb-9603-3def521d87e5.png" width="650" style="max-width: 650px">
5253

5354
### ghtop quad
5455

55-
The same information as `tail`, but in a split window showing separately PRs, issues, pushes, and releases. This view does not remove bot activity.
56+
The same information as `tail`, but in a split window showing separately PRs, issues, pushes, and releases.
5657

57-
<img src="https://user-images.githubusercontent.com/346999/101861560-2ecdcc80-3b25-11eb-9fba-25382b2df65f.png" width="900" style="max-width: 900px">
58+
<img src="https://user-images.githubusercontent.com/1483922/105074862-cb955b00-5a3d-11eb-99c5-3125bb98910b.png" width="650" style="max-width: 650px">
5859

5960
### ghtop users
6061

6162
A summary of activity for the most active current users.
6263

63-
<img src="https://user-images.githubusercontent.com/346999/101861612-4b6a0480-3b25-11eb-8124-19bb2434c27e.png" width="500" style="max-width: 500px">
64+
<img src="https://user-images.githubusercontent.com/1483922/105075363-8887b780-5a3e-11eb-9f7a-627268ac465f.png" width="650" style="max-width: 650px">
6465

6566
----
6667

‎ghtop/all_rich.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from rich import print as pr
2+
from rich.align import *
3+
from rich.bar import *
4+
from rich.color import *
5+
from rich.columns import *
6+
from rich.console import *
7+
from rich.emoji import *
8+
from rich.highlighter import *
9+
from rich.live import *
10+
from rich.logging import *
11+
from rich.markdown import *
12+
from rich.markup import *
13+
from rich.measure import *
14+
from rich.padding import *
15+
from rich.panel import *
16+
from rich.pretty import *
17+
from rich.progress_bar import *
18+
from rich.progress import *
19+
from rich.prompt import *
20+
from rich.protocol import *
21+
from rich.rule import *
22+
from rich.segment import *
23+
from rich.spinner import *
24+
from rich.status import *
25+
from rich.style import *
26+
from rich.styled import *
27+
from rich.syntax import *
28+
from rich.table import *
29+
from rich.text import *
30+
from rich.theme import *
31+
from rich.traceback import *
32+
from fastcore.all import *
33+
34+
@delegates(Style)
35+
def text(s, maxlen=None, **kwargs):
36+
"Create a styled `Text` object"
37+
if maxlen: s = truncstr(s, maxlen=maxlen)
38+
return Text(s, style=Style(**kwargs))
39+
40+
@delegates(Style)
41+
def segment(s, maxlen=None, space=' ', **kwargs):
42+
"Create a styled `Segment` object"
43+
if maxlen: s = truncstr(s, maxlen=maxlen, space=space)
44+
return Segment(s, style=Style(**kwargs))
45+
46+
class Segments(list):
47+
def __init__(self, options): self.w = options.max_width
48+
49+
@property
50+
def chars(self): return sum(o.cell_length for o in self)
51+
def txtlen(self, pct): return min((self.w-self.chars)*pct, 999)
52+
53+
@delegates(segment)
54+
def add(self, x, maxlen=None, pct=None, **kwargs):
55+
if pct: maxlen = math.ceil(self.txtlen(pct))
56+
self.append(segment(x, maxlen=maxlen, **kwargs))
57+
58+
@delegates(Table)
59+
def _grid(box=None, padding=0, collapse_padding=True, pad_edge=False, expand=False, show_header=False, show_edge=False, **kwargs):
60+
return Table(padding=padding, pad_edge=pad_edge, expand=expand, collapse_padding=collapse_padding,
61+
box=box, show_header=show_header, show_edge=show_edge, **kwargs)
62+
63+
@delegates(_grid)
64+
def grid(items, expand=True, no_wrap=True, **kwargs):
65+
g = _grid(expand=expand, **kwargs)
66+
for c in items[0]: g.add_column(no_wrap=no_wrap, justify='center')
67+
for i in items: g.add_row(*i)
68+
return g
69+
70+
Color = str_enum('Color', *ANSI_COLOR_NAMES)
71+
72+
class Deque(deque):
73+
def __rich__(self): return RenderGroup(*(filter(None, self)))
74+
75+
@delegates()
76+
class FixedPanel(Panel, GetAttr):
77+
_default='renderable'
78+
def __init__(self, height, **kwargs):
79+
super().__init__(Deque([' ']*height, maxlen=height), **kwargs)
80+
81+
@delegates(Style)
82+
def add(self, s:str, **kwargs):
83+
"Add styled `s` to panel"
84+
self.append(text(s, **kwargs))
85+

‎ghtop/ghtop.py‎

Lines changed: 98 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22

3-
__all__ = ['term', 'logfile', 'github_auth_device', 'limit_cb', 'api', 'Events', 'print_event', 'tail_events',
3+
__all__ = ['get_sparklines', 'ETYPES', 'term', 'tdim', 'limit_cb', 'Events', 'print_event', 'pct_comp', 'tail_events',
44
'watch_users', 'quad_logs', 'simple', 'main']
55

66

@@ -14,25 +14,29 @@
1414
from fastcore.foundation import *
1515
from fastcore.script import *
1616
from ghapi.all import *
17+
from .richext import *
18+
from .all_rich import (Console, Color, FixedPanel, box, Segments, Live,
19+
grid, ConsoleOptions, Progress, BarColumn, Spinner, Table)
1720

1821

19-
term = Terminal()
20-
logfile = Path("log.txt")
2122

23+
ETYPES=PushEvent,PullRequestEvent,IssuesEvent,ReleaseEvent
2224

23-
def github_auth_device(wb='', n_polls=9999):
24-
"Authenticate with GitHub, polling up to `n_polls` times to wait for completion"
25-
auth = GhDeviceAuth()
26-
print(f"First copy your one-time code: {term.yellow}{auth.user_code}{term.normal}")
27-
print(f"Then visit {auth.verification_uri} in your browser, and paste the code when prompted.")
28-
if not wb: wb = input("Shall we try to open the link for you? [y/n] ")
29-
if wb[0].lower()=='y': auth.open_browser()
25+
def get_sparklines():
26+
s1 = ESpark('Push', 'magenta', [PushEvent], mx=30)
27+
s2 = ESpark('PR', 'yellow', [PullRequestEvent, PullRequestReviewCommentEvent, PullRequestReviewEvent], mx=8)
28+
s3 = ESpark('Issues', 'green', [IssueCommentEvent,IssuesEvent], mx=6)
29+
s4 = ESpark('Releases', 'blue', [ReleaseEvent], mx=0.4)
30+
s5 = ESpark('All Events', 'orange', mx=45)
3031

31-
print("Waiting for authorization...", end='')
32-
token = auth.wait(lambda: print('.', end=''), n_polls=n_polls)
33-
if not token: return print('Authentication not complete!')
34-
print("Authenticated with GitHub")
35-
return token
32+
return Stats([s1,s2,s3,s4,s5], store=5, span=5, spn_lbl='5/s', show_freq=True)
33+
34+
35+
term = Terminal()
36+
37+
tdim = L(os.popen('stty size', 'r').read().split())
38+
if not tdim: theight,twidth = 15,15
39+
else: theight,twidth = tdim.map(lambda x: max(int(x)-4, 15))
3640

3741

3842
def _exit(msg):
@@ -46,18 +50,6 @@ def limit_cb(rem,quota):
4650
if rem < 1000: print(f"{w}\nRemaining calls: {rem} out of {quota}\n{w}", file=sys.stderr)
4751

4852

49-
def _get_api():
50-
path = Path.home()/".ghtop_token"
51-
if path.is_file():
52-
try: token = path.read_text().strip()
53-
except: _exit("Error reading token")
54-
else: token = github_auth_device()
55-
path.write_text(token)
56-
return GhApi(limit_cb=limit_cb, token=token)
57-
58-
api = _get_api()
59-
60-
6153
Events = dict(
6254
IssuesEvent_closed=('⭐', 'closed', noop),
6355
IssuesEvent_opened=('📫', 'opened', noop),
@@ -78,79 +70,120 @@ def _to_log(e):
7870
elif e.type == "ReleaseEvent": return f'🚀 {login} released {e.payload.release.tag_name} of {repo}'
7971

8072

81-
def print_event(e, commits_counter):
73+
def print_event(e, counter):
8274
res = _to_log(e)
8375
if res: print(res)
84-
elif e.type == "PushEvent": [commits_counter.update() for c in e.payload.commits]
76+
elif counter and e.type == "PushEvent": [counter.update() for c in e.payload.commits]
8577
elif e.type == "SecurityAdvisoryEvent": print(term.blink("SECURITY ADVISORY"))
8678

8779

88-
def tail_events(evt):
89-
"Print events from `fetch_events` along with a counter of push events"
90-
manager = enlighten.get_manager()
91-
commits = manager.counter(desc='Commits', unit='commits', color='green')
92-
for ev in evt: print_event(ev, commits)
80+
def pct_comp(api): return int(((5000-int(api.limit_rem)) / 5000) * 100)
9381

9482

95-
def _pr_row(*its): print(f"{its[0]: <30} {its[1]: <6} {its[2]: <5} {its[3]: <6} {its[4]: <7}")
96-
def watch_users(evts):
83+
def tail_events(evt, api):
84+
"Print events from `fetch_events` along with a counter of push events"
85+
p = FixedPanel(theight, box=box.HORIZONTALS, title='ghtop')
86+
s = get_sparklines()
87+
g = grid([[s], [p]])
88+
with Live(g):
89+
for e in evt:
90+
s.add_events(e)
91+
s.update_prog(pct_comp(api))
92+
p.append(e)
93+
g = grid([[s], [p]])
94+
95+
96+
def _user_grid():
97+
g = Table.grid(expand=True)
98+
g.add_column(justify="left")
99+
for i in range(4): g.add_column(justify="center")
100+
g.add_row("", "", "", "", "")
101+
g.add_row("User", "Events", "PRs", "Issues", "Pushes")
102+
return g
103+
104+
105+
def watch_users(evts, api):
97106
"Print a table of the users with the most events"
98107
users,users_events = defaultdict(int),defaultdict(lambda: defaultdict(int))
99-
while True:
100-
for x in islice(evts, 10):
101-
users[x.actor.login] += 1
102-
users_events[x.actor.login][x.type] += 1
103108

104-
print(term.clear())
105-
_pr_row("User", "Events", "PRs", "Issues", "Pushes")
106-
sorted_users = sorted(users.items(), key=lambda o: (o[1],o[0]), reverse=True)
107-
for u in sorted_users[:20]:
108-
_pr_row(*u, *itemgetter('PullRequestEvent','IssuesEvent','PushEvent')(users_events[u[0]]))
109+
with Live() as live:
110+
s = get_sparklines()
111+
while True:
112+
for x in islice(evts, 10):
113+
users[x.actor.login] += 1
114+
users_events[x.actor.login][x.type] += 1
115+
s.add_events(x)
109116

117+
ig = _user_grid()
118+
sorted_users = sorted(users.items(), key=lambda o: (o[1],o[0]), reverse=True)
119+
for u in sorted_users[:theight]:
120+
data = (*u, *itemgetter('PullRequestEvent','IssuesEvent','PushEvent')(users_events[u[0]]))
121+
ig.add_row(*L(data).map(str))
110122

111-
def _push_to_log(e): return f"{e.actor.login} pushed {len(e.payload.commits)} commits to repo {e.repo.name}"
112-
def _logwin(title,color): return Log(title=title,border_color=2,color=color)
113-
114-
def quad_logs(evts):
115-
"Print 4 panels, showing most recent issues, commits, PRs, and releases"
116-
term.enter_fullscreen()
117-
ui = HSplit(VSplit(_logwin('Issues', color=7), _logwin('Commits' , color=3)),
118-
VSplit(_logwin('Pull Requests', color=4), _logwin('Releases', color=5)))
123+
s.update_prog(pct_comp(api))
124+
g = grid([[s], [ig]])
125+
live.update(g)
119126

120-
issues,commits,prs,releases = all_items = ui.items[0].items+ui.items[1].items
121-
for o in all_items: o.append(" ")
122127

123-
d = dict(PushEvent=commits, IssuesEvent=issues, IssueCommentEvent=issues, PullRequestEvent=prs, ReleaseEvent=releases)
124-
while True:
125-
for x in islice(evts, 10):
126-
f = [_to_log,_push_to_log][x.type == 'PushEvent']
127-
if x.type in d: d[x.type].append(f(x)[:95])
128-
ui.display()
128+
def _panelDict2Grid(pd):
129+
ispush,ispr,isiss,isrel = pd.values()
130+
return grid([[ispush,ispr],[isiss,isrel]], width=twidth)
129131

130132

131-
def simple(evts):
133+
def quad_logs(evts, api):
134+
"Print 4 panels, showing most recent issues, commits, PRs, and releases"
135+
pd = {o:FixedPanel(height=(theight//2)-1,
136+
width=(twidth//2)-1,
137+
box=box.HORIZONTALS,
138+
title=camel2words(remove_suffix(o.__name__,'Event'))) for o in ETYPES}
139+
p = _panelDict2Grid(pd)
140+
s = get_sparklines()
141+
g = grid([[s], [p]])
142+
with Live(g):
143+
for e in evts:
144+
s.add_events(e)
145+
s.update_prog(pct_comp(api))
146+
typ = type(e)
147+
if typ in pd: pd[typ].append(e)
148+
p = _panelDict2Grid(pd)
149+
g = grid([[s], [p]])
150+
151+
152+
def simple(evts, api):
132153
for ev in evts: print(f"{ev.actor.login} {ev.type} {ev.repo.name}")
133154

134155

156+
def _get_token():
157+
path = Path.home()/".ghtop_token"
158+
if path.is_file():
159+
try: return path.read_text().strip()
160+
except: _exit("Error reading token")
161+
else: token = github_auth_device()
162+
path.write_text(token)
163+
return token
164+
165+
135166
def _signal_handler(sig, frame):
136167
if sig != signal.SIGINT: return
137168
print(term.exit_fullscreen(),term.clear(),term.normal)
138169
sys.exit(0)
139170

140171
_funcs = dict(tail=tail_events, quad=quad_logs, users=watch_users, simple=simple)
141-
_filts = str_enum('_filts', 'user', 'repo', 'org')
172+
_filts = str_enum('_filts', 'users', 'repo', 'org')
142173
_OpModes = str_enum('_OpModes', *_funcs)
143174

144175
@call_parse
145176
def main(mode: Param("Operation mode to run", _OpModes),
146177
include_bots: Param("Include bots (there's a lot of them!)", store_true)=False,
147178
types: Param("Comma-separated types of event to include (e.g PushEvent)", str)='',
179+
pause: Param("Number of seconds to pause between requests to the GitHub api", float)=0.4,
148180
filt: Param("Filtering method", _filts)=None,
149181
filtval: Param("Value to filter by (for `repo` use format `owner/repo`)", str)=None):
150182
signal.signal(signal.SIGINT, _signal_handler)
151183
types = types.split(',') if types else None
152184
if filt and not filtval: _exit("Must pass `filter_value` if passing `filter_type`")
153185
if filtval and not filt: _exit("Must pass `filter_type` if passing `filter_value`")
154186
kwargs = {filt:filtval} if filt else {}
155-
evts = api.fetch_events(types=types, incl_bot=include_bots, **kwargs)
156-
_funcs[mode](evts)
187+
api = GhApi(limit_cb=limit_cb, token=_get_token())
188+
evts = api.fetch_events(types=types, incl_bot=include_bots, pause=float(pause), **kwargs)
189+
_funcs[mode](evts, api)

0 commit comments

Comments
 (0)