├── .github
└── workflows
│ └── release.yml
├── README.md
├── go.mod
├── go.sum
├── main.go
├── sketch.txt
└── util
└── util.go
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 | on:
3 | push:
4 | tags:
5 | - "v*"
6 | permissions:
7 | contents: write
8 |
9 | jobs:
10 | release:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 | - uses: cli/gh-extension-precompile@v1
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gh actions-status
2 |
3 | _being an extension to view the overall health of an organization's use of actions_
4 |
5 |
6 |
7 | ## Usage
8 |
9 | By default, this command shows actions health for the last 30 days.
10 |
11 | ```bash
12 | # See the actions health for an organization
13 | gh actions-status cli
14 |
15 | # See health for a different time period in either hours or days
16 | gh actions-status -l 12h
17 | gh actions-status -l 7d
18 |
19 | # See health for an arbitrary list of repositories within an org
20 | gh actions-status cli -r "cli,go-gh"
21 |
22 | # See workflow runs with 'failure' status for an arbitrary list of repositories within an org
23 | gh actions-status cli -r "cli,go-gh" -s "failure"
24 |
25 | # See the actions health for all the repositories of a user
26 | gh actions-status rsese
27 | ```
28 |
29 | ## Installation
30 |
31 | ```bash
32 | gh extension install rsese/gh-actions-status
33 | ```
34 |
35 | ## Authors
36 |
37 | Robert Sese , vilmibm
38 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/vilmibm/actions-dashboard
2 |
3 | go 1.15
4 |
5 | require (
6 | github.com/charmbracelet/lipgloss v0.4.0
7 | github.com/cli/go-gh v1.2.1
8 | github.com/kr/text v0.2.0 // indirect
9 | github.com/spf13/pflag v1.0.5
10 | golang.org/x/term v0.5.0
11 | )
12 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
3 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
4 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
5 | github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
6 | github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
7 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
8 | github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI=
9 | github.com/cli/go-gh v1.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o=
10 | github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM=
11 | github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
12 | github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
13 | github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk=
14 | github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA=
15 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
20 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
21 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
22 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
23 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
24 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
25 | github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs=
26 | github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
27 | github.com/itchyny/gojq v0.12.8/go.mod h1:gE2kZ9fVRU0+JAksaTzjIlgnCa2akU+a1V0WXgJQN5c=
28 | github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
29 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
30 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
31 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
32 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
33 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
34 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
35 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
36 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
37 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
38 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
39 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
40 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
41 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
42 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
43 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
44 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
45 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
46 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
47 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
48 | github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
49 | github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
50 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
51 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
52 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
53 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
54 | github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
55 | github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
56 | github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
57 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
58 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
59 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
60 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
61 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
62 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
63 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
64 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
65 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
66 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
67 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
68 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
69 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
70 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
71 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
72 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
73 | github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
74 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
75 | github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
76 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
77 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
78 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
79 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
80 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
81 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
82 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
83 | golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
84 | golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
85 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
86 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
87 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
88 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
89 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
90 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
91 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
92 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
93 | golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
94 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
95 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
96 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
97 | golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
98 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
99 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
101 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
102 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
103 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
104 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
105 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
106 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
107 | golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
108 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
109 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
110 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
111 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
112 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
113 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
114 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
115 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
116 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
117 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
118 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
119 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
120 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
121 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
122 | gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
123 | gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
124 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
125 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
126 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
127 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "math"
9 | "os"
10 | "strconv"
11 | "strings"
12 | "text/template"
13 | "time"
14 |
15 | "golang.org/x/term"
16 |
17 | "github.com/charmbracelet/lipgloss"
18 | "github.com/cli/go-gh"
19 | flag "github.com/spf13/pflag"
20 | "github.com/vilmibm/actions-dashboard/util"
21 | )
22 |
23 | const defaultMaxRuns = 5
24 | const defaultWorkflowNameLength = 17
25 | const defaultApiCacheTime = "60m"
26 |
27 | type run struct {
28 | Finished time.Time
29 | Elapsed time.Duration
30 | Status string
31 | Conclusion string
32 | URL string
33 | }
34 |
35 | type workflow struct {
36 | Name string
37 | Runs []run
38 | BillableMs int
39 | }
40 |
41 | func (w *workflow) RenderHealth() string {
42 | successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#32cd32"))
43 | neutralStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#808080"))
44 | failedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#dc143c"))
45 |
46 | var results string
47 | health := workflowHealth(*w)
48 |
49 | for _, r := range health {
50 | switch r {
51 | case '✓':
52 | results += successStyle.Render("✓")
53 | case '-':
54 | results += neutralStyle.Render("-")
55 | default:
56 | results += failedStyle.Render("x")
57 | }
58 | }
59 |
60 | return results
61 | }
62 |
63 | func (w *workflow) AverageElapsed() time.Duration {
64 | var totalTime int
65 | var averageTime int
66 |
67 | for i, r := range w.Runs {
68 | if i > defaultMaxRuns {
69 | break
70 | }
71 |
72 | totalTime += int(r.Elapsed.Seconds())
73 | }
74 |
75 | averageTime = totalTime / defaultMaxRuns
76 |
77 | s := fmt.Sprintf("%ds", averageTime)
78 | d, _ := time.ParseDuration(s)
79 |
80 | return d
81 | }
82 |
83 | func truncateWorkflowName(name string, length int) string {
84 | if len(name) > length {
85 | return name[:length] + "..."
86 | }
87 |
88 | return name
89 | }
90 |
91 | func getTerminalWidth() int {
92 | if !term.IsTerminal(int(os.Stdout.Fd())) {
93 | return 80
94 | }
95 |
96 | width, _, err := term.GetSize(int(os.Stdout.Fd()))
97 |
98 | if err != nil {
99 | panic(err.Error())
100 | }
101 |
102 | return width
103 | }
104 |
105 | func (w *workflow) RenderCard() string {
106 | workflowNameStyle := lipgloss.NewStyle().Bold(true)
107 | labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#808080"))
108 | var tmpl *template.Template
109 | tmplData := struct {
110 | Name string
111 | AvgElapsed time.Duration
112 | Health string
113 | BillableMs int
114 | PrettyMS func(int) string
115 | Label func(string) string
116 | }{
117 | Name: workflowNameStyle.Render(truncateWorkflowName(w.Name, defaultWorkflowNameLength)),
118 | AvgElapsed: w.AverageElapsed(),
119 | Health: w.RenderHealth(),
120 | BillableMs: w.BillableMs,
121 | PrettyMS: util.PrettyMS,
122 | Label: func(s string) string {
123 | return labelStyle.Render(s)
124 | },
125 | }
126 |
127 | // Assumes that run data is time filtered already
128 | // TODO add color etc in here:
129 | if len(w.Runs) == 0 {
130 | tmpl, _ = template.New("emptyWorkflowCard").Parse(
131 | `{{ .Name }}
132 | {{call .Label "No runs"}}`)
133 | } else {
134 | tmpl, _ = template.New("workflowCard").Parse(
135 | `{{ .Name }}
136 | {{call .Label "Health:"}} {{ .Health }}
137 | {{call .Label "Avg elapsed:"}} {{ .AvgElapsed }}
138 | {{- if .BillableMs }}
139 | {{call .Label "Billable time:"}} {{call .PrettyMS .BillableMs }}{{end}}`)
140 | }
141 | buf := bytes.Buffer{}
142 | _ = tmpl.Execute(&buf, tmplData)
143 | return buf.String()
144 | }
145 |
146 | type repositoryData struct {
147 | HtmlUrl string `json:"html_url"`
148 | Name string `json:"full_name"`
149 | Private bool
150 | Workflows []*workflow
151 | }
152 |
153 | type options struct {
154 | Repositories []string
155 | Last time.Duration
156 | Selector string
157 | Status string
158 | }
159 |
160 | func workflowHealth(w workflow) string {
161 | health := ""
162 |
163 | for i, r := range w.Runs {
164 | if i > defaultMaxRuns {
165 | break
166 | }
167 |
168 | if r.Status != "completed" {
169 | health += "-"
170 | continue
171 | }
172 |
173 | switch r.Conclusion {
174 | case "success":
175 | health += "✓"
176 | case "skipped", "cancelled", "neutral":
177 | health += "-"
178 | default:
179 | health += "x"
180 | }
181 | }
182 |
183 | return health
184 | }
185 |
186 | func noTerminalRender(repos []*repositoryData) error {
187 | for _, r := range repos {
188 | if len(r.Workflows) == 0 {
189 | continue
190 | }
191 | fmt.Println()
192 | fmt.Println(r.Name)
193 | fmt.Printf("%s/actions\n", r.HtmlUrl)
194 | fmt.Println()
195 |
196 | for _, w := range r.Workflows {
197 | fmt.Println()
198 | fmt.Printf("%s:\n", w.Name)
199 | if len(w.Runs) == 0 {
200 | fmt.Printf(" No runs\n")
201 | } else {
202 | health := workflowHealth(*w)
203 |
204 | fmt.Printf(" %-15s %v\n", "Health: ", health)
205 | fmt.Printf(" %-15s %v\n", "Avg elapsed: ", w.AverageElapsed())
206 | fmt.Printf(" %-15s %v\n", "Billable time: ", util.PrettyMS(w.BillableMs))
207 | }
208 | }
209 |
210 | fmt.Println()
211 | }
212 |
213 | return nil
214 | }
215 |
216 | func terminalRender(repos []*repositoryData) error {
217 | columnWidth := defaultWorkflowNameLength + 5 // account for ellipsis and padding/border
218 | cardsPerRow := (getTerminalWidth() / columnWidth) - 1
219 |
220 | cardStyle := lipgloss.NewStyle().
221 | Align(lipgloss.Left).
222 | Padding(1).
223 | Width(columnWidth).
224 | BorderStyle(lipgloss.DoubleBorder()).
225 | BorderForeground(lipgloss.Color("63"))
226 |
227 | repoNameStyle := lipgloss.NewStyle().Bold(true)
228 | repoHintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#808080")).Italic(true)
229 |
230 | for _, r := range repos {
231 | if len(r.Workflows) == 0 {
232 | continue
233 | }
234 | fmt.Println()
235 | fmt.Print(repoNameStyle.Render(r.Name))
236 | fmt.Print(repoHintStyle.Render(fmt.Sprintf(" %s/actions\n", r.HtmlUrl)))
237 | fmt.Println()
238 |
239 | totalRows := int(math.Ceil(float64(len(r.Workflows)) / float64(cardsPerRow)))
240 | cardRows := make([][]string, totalRows)
241 | rowIndex := 0
242 |
243 | for _, w := range r.Workflows {
244 | if len(cardRows[rowIndex]) == cardsPerRow {
245 | rowIndex++
246 | }
247 |
248 | cardRows[rowIndex] = append(cardRows[rowIndex], cardStyle.Render(w.RenderCard()))
249 | }
250 |
251 | for _, row := range cardRows {
252 | fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, row...))
253 | }
254 | }
255 |
256 | return nil
257 | }
258 |
259 | func _main(opts *options) error {
260 | selector := opts.Selector
261 | last := opts.Last
262 |
263 | repos, err := populateRepos(opts)
264 | if err != nil {
265 | return fmt.Errorf("could not fetch repository data: %w", err)
266 | }
267 |
268 | totalBillableMs := 0
269 |
270 | for _, r := range repos {
271 | workflows, err := getWorkflows(*r, last, opts)
272 | if err != nil {
273 | return err
274 | }
275 |
276 | r.Workflows = workflows
277 |
278 | for _, w := range workflows {
279 | totalBillableMs += w.BillableMs
280 | }
281 | }
282 |
283 | if term.IsTerminal(int(os.Stdout.Fd())) {
284 | titleStyle := lipgloss.NewStyle().Bold(true).Align(lipgloss.Center).Width(getTerminalWidth())
285 | subTitleStyle := lipgloss.NewStyle().Align(lipgloss.Center).Width(getTerminalWidth())
286 |
287 | fmt.Println(titleStyle.Render(fmt.Sprintf("GitHub Actions dashboard for %s for the past %s", selector, util.FuzzyAgo(opts.Last))))
288 | fmt.Println(subTitleStyle.Render(fmt.Sprintf("Total billable time: %s", util.PrettyMS(totalBillableMs))))
289 | terminalRender(repos)
290 | } else {
291 | fmt.Printf("GitHub Actions dashboard for %s for the past %s\n", selector, util.FuzzyAgo(opts.Last))
292 | fmt.Printf("Total billable time: %s\n", util.PrettyMS(totalBillableMs))
293 | noTerminalRender(repos)
294 | }
295 |
296 | return nil
297 | }
298 |
299 | func populateRepos(opts *options) ([]*repositoryData, error) {
300 | result := []*repositoryData{}
301 | if len(opts.Repositories) > 0 {
302 | for _, repoName := range opts.Repositories {
303 | repoData, err := getRepo(opts.Selector, repoName)
304 | if err != nil {
305 | return nil, fmt.Errorf("failed to fetch data for %s/%s: %w", opts.Selector, repoName, err)
306 | }
307 | result = append(result, repoData)
308 | }
309 |
310 | return result, nil
311 | }
312 |
313 | var orgErr error
314 | var userErr error
315 | result, orgErr = getAllRepos(fmt.Sprintf("orgs/%s/repos", opts.Selector))
316 | if orgErr != nil {
317 | result, userErr = getAllRepos(fmt.Sprintf("users/%s/repos", opts.Selector))
318 | if userErr != nil {
319 | return nil, fmt.Errorf("could not find a user or org called '%s': %s; %s", opts.Selector, orgErr, userErr)
320 | }
321 | }
322 |
323 | return result, nil
324 | }
325 |
326 | func getRepo(owner, name string) (*repositoryData, error) {
327 | path := fmt.Sprintf("repos/%s/%s", owner, name)
328 | var stdout bytes.Buffer
329 | var data repositoryData
330 | var err error
331 | if stdout, _, err = gh.Exec("api", "--cache", defaultApiCacheTime, path); err != nil {
332 | return nil, err
333 | }
334 | if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
335 | return nil, err
336 | }
337 |
338 | return &data, nil
339 | }
340 |
341 | func getAllRepos(path string) ([]*repositoryData, error) {
342 | stdout, _, err := gh.Exec("api", "--cache", defaultApiCacheTime, path)
343 | if err != nil {
344 | return nil, err
345 | }
346 |
347 | repoData := []*repositoryData{}
348 | err = json.Unmarshal(stdout.Bytes(), &repoData)
349 | if err != nil {
350 | return nil, err
351 | }
352 |
353 | return repoData, nil
354 | }
355 |
356 | func getWorkflows(repoData repositoryData, last time.Duration, opts *options) ([]*workflow, error) {
357 | workflowsPath := fmt.Sprintf("repos/%s/actions/workflows", repoData.Name)
358 |
359 | stdout, _, err := gh.Exec("api", "--cache", defaultApiCacheTime, workflowsPath, "--jq", ".workflows")
360 | if err != nil {
361 | return nil, err
362 | }
363 |
364 | type workflowsPayload struct {
365 | Id int `json:"id"`
366 | State string
367 | Name string
368 | URL string `json:"url"`
369 | }
370 |
371 | p := []workflowsPayload{}
372 | err = json.Unmarshal(stdout.Bytes(), &p)
373 | if err != nil {
374 | return nil, err
375 | }
376 |
377 | out := []*workflow{}
378 |
379 | type runPayload struct {
380 | Id int `json:"id"`
381 | CreatedAt time.Time `json:"created_at"`
382 | UpdatedAt time.Time `json:"updated_at"`
383 | Status string
384 | Conclusion string
385 | URL string
386 | }
387 |
388 | type billablePayload struct {
389 | MacOs struct {
390 | TotalMs int `json:"total_ms"`
391 | } `json:"MACOS"`
392 | Windows struct {
393 | TotalMs int `json:"total_ms"`
394 | } `json:"WINDOWS"`
395 | Ubuntu struct {
396 | TotalMs int `json:"total_ms"`
397 | } `json:"UBUNTU"`
398 | }
399 |
400 | var totalMs int
401 |
402 | for _, w := range p {
403 | if strings.HasPrefix(w.State, "disabled") {
404 | continue
405 | }
406 |
407 | var runsPath string
408 | if opts.Status != "" {
409 | runsPath = fmt.Sprintf("%s/runs?status=%s", w.URL, opts.Status)
410 | } else {
411 | runsPath = fmt.Sprintf("%s/runs", w.URL)
412 | }
413 |
414 | stdout, _, err = gh.Exec("api", "--cache", defaultApiCacheTime, runsPath, "--jq", ".workflow_runs")
415 | if err != nil {
416 | return nil, fmt.Errorf("could not call gh: %w", err)
417 | }
418 | rs := []runPayload{}
419 | err = json.Unmarshal(stdout.Bytes(), &rs)
420 | if err != nil {
421 | return nil, fmt.Errorf("could not parse json: %w", err)
422 | }
423 |
424 | runs := []run{}
425 |
426 | for _, r := range rs {
427 | rr := run{Status: r.Status, Conclusion: r.Conclusion, URL: r.URL}
428 |
429 | if r.Status == "completed" {
430 | rr.Finished = r.UpdatedAt
431 | rr.Elapsed = r.UpdatedAt.Sub(r.CreatedAt)
432 | finishedAgo := time.Since(rr.Finished)
433 |
434 | if last-finishedAgo > 0 {
435 | runs = append(runs, rr)
436 | }
437 | }
438 | }
439 |
440 | if repoData.Private && strings.Index(repoData.HtmlUrl, "https://github.com") == 0 {
441 | for _, r := range runs {
442 | runTimingPath := fmt.Sprintf("%s/timing", r.URL)
443 | stdout, _, err = gh.Exec("api", "--cache", defaultApiCacheTime, runTimingPath, "--jq", ".billable")
444 | if err != nil {
445 | return nil, fmt.Errorf("could not call gh: %w", err)
446 | }
447 | bp := billablePayload{}
448 | err = json.Unmarshal(stdout.Bytes(), &bp)
449 | if err != nil {
450 | return nil, fmt.Errorf("could not parse json: %w", err)
451 | }
452 |
453 | totalMs += bp.MacOs.TotalMs + bp.Windows.TotalMs + bp.Ubuntu.TotalMs
454 | }
455 | }
456 |
457 | out = append(out, &workflow{
458 | Name: w.Name,
459 | Runs: runs,
460 | BillableMs: totalMs,
461 | })
462 | }
463 |
464 | return out, nil
465 | }
466 |
467 | func parseArgs() (*options, error) {
468 | var selector string
469 |
470 | repositories := flag.StringSliceP("repos", "r", []string{}, "One or more repository names from the provided org or user")
471 | last := flag.StringP("last", "l", "30d", "What period of time to cover in hours (eg 1h) or days (eg 30d). Default: 30d")
472 | runStatus := flag.StringP("status", "s", "", "What workflow run status (eg completed, cancelled, failure, success) to query for")
473 |
474 | flag.Parse()
475 |
476 | // Try to determine user or org name form single argument
477 | if len(flag.Args()) == 1 {
478 | // Single argument to use as org/user name
479 | selector = flag.Arg(0)
480 | } else if len(flag.Args()) != 0 {
481 | // Too many arguments, don't try to infer anything, just fail
482 | return nil, errors.New("need exactly one argument, either an organization or user name.")
483 | } else if _, stderr, err := gh.Exec("auth", "status"); err != nil {
484 | // Couldn't infer username, gh auth returned error
485 | return nil, fmt.Errorf("need exactly one argument, either an organization or user name. Could not determine username from auth status: %w", err)
486 | } else if status := stderr.String(); status != "" {
487 | // Successfully got auth status, look through it for something that
488 | // looks like a username.
489 |
490 | search := "Logged in to github.com as "
491 | for _, line := range strings.Split(status, "\n") {
492 | if start := strings.Index(line, search); start >= 0 {
493 | tokens := strings.Split(line[start+len(search):], " ")
494 |
495 | // Stop looking if username was found
496 | if len(tokens) > 0 {
497 | selector = tokens[0]
498 | break
499 | }
500 | }
501 | }
502 | } else {
503 | // Couldn't infer username
504 | return nil, errors.New("need exactly one argument, either an organization or user name.")
505 | }
506 |
507 | lastVal := *last
508 | timeUnit := string(lastVal[len(lastVal)-1])
509 |
510 | // Go cannot parse duration "1d" which is stupid; need to convert it to hours before we can get a proper duration.
511 | if timeUnit == "d" {
512 | asNum, err := strconv.Atoi(lastVal[0 : len(lastVal)-1])
513 | if err != nil {
514 | return nil, fmt.Errorf("could not parse number: %w", err)
515 | }
516 | lastVal = fmt.Sprintf("%dh", asNum*24)
517 | }
518 |
519 | if timeUnit != "h" && timeUnit != "d" {
520 | return nil, fmt.Errorf("report duration should be in hours or duration (eg 1h or 30d)")
521 | }
522 |
523 | duration, err := time.ParseDuration(lastVal)
524 |
525 | if err != nil {
526 | return nil, fmt.Errorf("failed to parse duration: %w", err)
527 | }
528 |
529 | return &options{
530 | Repositories: *repositories,
531 | Last: duration,
532 | Selector: selector,
533 | Status: *runStatus,
534 | }, nil
535 | }
536 |
537 | func main() {
538 | opts, err := parseArgs()
539 | if err != nil {
540 | fmt.Fprintf(os.Stderr, "failed to parse arguments: %s\n", err)
541 | os.Exit(1)
542 | }
543 |
544 | // TODO testing is annoying bc of flag.Parse() in _main
545 | err = _main(opts)
546 | if err != nil {
547 | fmt.Fprintf(os.Stderr, "%s\n", err)
548 | os.Exit(1)
549 | }
550 | }
551 |
--------------------------------------------------------------------------------
/sketch.txt:
--------------------------------------------------------------------------------
1 | Actions Health Dashboard as of 9/1/2021 for cli organization
2 |
3 | Total billable minutes: 1000
4 | 98% completed this month
5 | 97% passed this month
6 |
7 | Repo: cli
8 | 99% completed this month
9 | 98% passed this month
10 | 5 runs in progress
11 |
12 | Workflow one Workflow Two Workflow Three
13 | Avg elapsed: 1min Avg elapsed: 39sec Avg Elapsed: 5min
14 | Health: x-✓✓✓✓✓✓
15 |
16 | Repo: oauth
17 | 100% completed this month
18 | 100% passed this month
19 |
20 | Workflow Four
21 | Avg elapsed: 1min
22 | Health: ✓✓✓✓✓✓
23 |
--------------------------------------------------------------------------------
/util/util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "fmt"
5 | "time"
6 | )
7 |
8 | func Pluralize(num int, thing string) string {
9 | if num == 1 {
10 | return fmt.Sprintf("%d %s", num, thing)
11 | }
12 | return fmt.Sprintf("%d %ss", num, thing)
13 | }
14 |
15 | func FuzzyAgo(ago time.Duration) string {
16 | if ago < 24*time.Hour {
17 | return Pluralize(int(ago.Hours()), "hour")
18 | }
19 | if ago < 30*24*time.Hour {
20 | return Pluralize(int(ago.Hours())/24, "day")
21 | }
22 | if ago < 365*24*time.Hour {
23 | return Pluralize(int(ago.Hours())/24/30, "month")
24 | }
25 |
26 | return Pluralize(int(ago.Hours()/24/365), "year")
27 | }
28 |
29 | func PrettyMS(ms int) string {
30 | if ms == 60000 {
31 | return "1m"
32 | }
33 | if ms < 60000 {
34 | return fmt.Sprintf("%dms", ms)
35 | }
36 | return fmt.Sprintf("%.2fm", float32(ms)/60000)
37 | }
38 |
--------------------------------------------------------------------------------