├── .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 | screenshot of extension 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 | --------------------------------------------------------------------------------