├── .github └── workflows │ └── clojure.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build ├── css │ ├── semantic.min.css │ └── themes │ │ └── default │ │ └── assets │ │ ├── fonts │ │ ├── icons.eot │ │ ├── icons.otf │ │ ├── icons.svg │ │ ├── icons.ttf │ │ ├── icons.woff │ │ └── icons.woff2 │ │ └── images │ │ └── flags.png ├── img │ └── favicon.png ├── index.html └── js │ └── compiled │ └── app.js ├── deps.edn ├── html ├── css │ └── themes │ │ └── default │ │ └── assets │ │ ├── fonts │ │ ├── icons.eot │ │ ├── icons.otf │ │ ├── icons.svg │ │ ├── icons.ttf │ │ ├── icons.woff │ │ └── icons.woff2 │ │ └── images │ │ └── flags.png ├── data.edn ├── img │ └── favicon.png ├── index.html └── js │ └── compiled │ └── app.js ├── project.clj ├── resources └── public │ └── index.html ├── src ├── clj │ └── pullq │ │ └── main.clj └── cljs │ └── pullq │ ├── config.cljs │ ├── db.cljs │ ├── events.cljs │ ├── main.cljs │ ├── subs.cljs │ └── views.cljs └── test └── pullq └── test_main.clj /.github/workflows/clojure.yml: -------------------------------------------------------------------------------- 1 | name: Clojure 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Test and Build 11 | run: lein do clean, compile :all, test, uberjar, cljsbuild once min 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /target 3 | /*-init.clj 4 | /resources/public/js/compiled 5 | /resources/public/data.edn 6 | /build/data.edn 7 | out 8 | /site 9 | /figwheel_server.log 10 | /pullq.conf 11 | /.nrepl-port 12 | /.cpcache 13 | /build/.cpcache 14 | /build/.nrepl-port 15 | /.rebel_readline_history 16 | .lein-failures 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM clojure:openjdk-11-lein AS build 2 | VOLUME /etc/pullq /outdir 3 | 4 | ADD . /src 5 | 6 | RUN cd /src && \ 7 | lein do clean, uberjar, cljsbuild once min && \ 8 | cp -r build /build && \ 9 | cp resources/public/js/compiled/app.js /build/js/compiled/app.js && \ 10 | cp target/pullq.jar /build 11 | 12 | FROM openjdk:11 AS run 13 | 14 | COPY --from=build /build build 15 | 16 | # When running this will mean the environment will need to contain GITHUB_TOKEN 17 | ENTRYPOINT java -jar /build/pullq.jar -f /etc/pullq/pullq.conf -S /outdir 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Exoscale 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pullq: Pull request queue visualization 2 | ======================================== 3 | 4 | ![GH Action Badge](https://github.com/exoscale/pullq/workflows/Clojure/badge.svg) 5 | 6 | This is a tool inspired by [review 7 | gator](https://github.com/fginther/review-gator) with a number of 8 | differences: 9 | 10 | - Github-only support 11 | - A departure in the presentation style 12 | - Decoupling of stats gathering and presentation 13 | - Additional dynamic filters (authors, repositories, labels, status) 14 | 15 | ![screenshot](https://i.imgur.com/grOsAsw.png) 16 | 17 | ## tl;dr 18 | 19 | To get from nothing to a working pullq assuming you have a 20 | [clojure](https://clojure.org) environment already setup: 21 | 22 | ``` 23 | git clone https://github.com/exoscale/pullq.git 24 | cd pullq 25 | cat > pullq.conf < 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src/clj"] 2 | :deps 3 | {org.clojure/clojure {:mvn/version "1.10.0"} 4 | org.clojure/tools.cli {:mvn/version "0.4.1"} 5 | irresponsible/tentacles {:mvn/version "0.6.3"} 6 | clj-time {:mvn/version "0.15.1"}}} 7 | -------------------------------------------------------------------------------- /html/css/themes/default/assets/fonts/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/pullq/a1f4ed408cda61ac1454f5e7cf60b2f514702765/html/css/themes/default/assets/fonts/icons.eot -------------------------------------------------------------------------------- /html/css/themes/default/assets/fonts/icons.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/pullq/a1f4ed408cda61ac1454f5e7cf60b2f514702765/html/css/themes/default/assets/fonts/icons.otf -------------------------------------------------------------------------------- /html/css/themes/default/assets/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/pullq/a1f4ed408cda61ac1454f5e7cf60b2f514702765/html/css/themes/default/assets/fonts/icons.ttf -------------------------------------------------------------------------------- /html/css/themes/default/assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/pullq/a1f4ed408cda61ac1454f5e7cf60b2f514702765/html/css/themes/default/assets/fonts/icons.woff -------------------------------------------------------------------------------- /html/css/themes/default/assets/fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/pullq/a1f4ed408cda61ac1454f5e7cf60b2f514702765/html/css/themes/default/assets/fonts/icons.woff2 -------------------------------------------------------------------------------- /html/css/themes/default/assets/images/flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/pullq/a1f4ed408cda61ac1454f5e7cf60b2f514702765/html/css/themes/default/assets/images/flags.png -------------------------------------------------------------------------------- /html/data.edn: -------------------------------------------------------------------------------- 1 | [{:labels [], 2 | :updated 1591963084, 3 | :created 1591963037, 4 | :login "pyr", 5 | :title 6 | "Remove the unused - that we know of - standalone server. Add sort order toggle", 7 | :status 8 | {:min-oks 1, 9 | :oks 0, 10 | :comments 0, 11 | :changes 0, 12 | :open? true, 13 | :color "yellow", 14 | :counter 1, 15 | :type "ok missing"}, 16 | :avatar "https://avatars2.githubusercontent.com/u/110280?v=4", 17 | :url "https://github.com/exoscale/pullq/pull/22", 18 | :mergeable-state "clean", 19 | :reviews [], 20 | :repo {:name "pullq", :url "https://github.com/exoscale/pullq"}} 21 | {:labels [], 22 | :updated 1591949542, 23 | :created 1591776560, 24 | :login "falzm", 25 | :title "Add support for Network Load Balancer resources management", 26 | :status 27 | {:min-oks 1, 28 | :oks 0, 29 | :comments 0, 30 | :changes 0, 31 | :open? true, 32 | :color "yellow", 33 | :counter 1, 34 | :type "ok missing"}, 35 | :avatar "https://avatars2.githubusercontent.com/u/1122379?v=4", 36 | :url "https://github.com/exoscale/cli/pull/256", 37 | :mergeable-state "clean", 38 | :reviews [], 39 | :repo {:name "cli", :url "https://github.com/exoscale/cli"}} 40 | {:labels ["WIP :construction:"], 41 | :updated 1591605031, 42 | :created 1591315518, 43 | :login "J0WI", 44 | :title "Use Alpine base", 45 | :status 46 | {:min-oks 1, 47 | :oks 0, 48 | :comments 0, 49 | :changes 0, 50 | :open? true, 51 | :color "yellow", 52 | :counter 1, 53 | :type "ok missing"}, 54 | :avatar "https://avatars2.githubusercontent.com/u/5710638?v=4", 55 | :url "https://github.com/exoscale/cli/pull/254", 56 | :mergeable-state "clean", 57 | :reviews [], 58 | :repo {:name "cli", :url "https://github.com/J0WI/cli"}} 59 | {:labels ["HOLD :hand:" "enhancement"], 60 | :updated 1588840928, 61 | :created 1588016225, 62 | :login "janoszen", 63 | :title "Full directory synchronization", 64 | :status 65 | {:min-oks 1, 66 | :oks 0, 67 | :comments 1, 68 | :changes 1, 69 | :open? true, 70 | :color "red", 71 | :counter 1, 72 | :type "blocker"}, 73 | :avatar "https://avatars0.githubusercontent.com/u/662664?v=4", 74 | :url "https://github.com/exoscale/cli/pull/240", 75 | :mergeable-state "clean", 76 | :reviews 77 | [{:login "falzm", 78 | :url 79 | "https://github.com/exoscale/cli/pull/240#pullrequestreview-405799246", 80 | :avatar 81 | "https://avatars0.githubusercontent.com/u/1122379?u=c0c6b9a45020abc70b7b6f680a775506b08d880b&v=4", 82 | :state :needs-changes, 83 | :age 1588686906} 84 | {:login "pierre-emmanuelJ", 85 | :url 86 | "https://github.com/exoscale/cli/pull/240#pullrequestreview-405824924", 87 | :avatar 88 | "https://avatars2.githubusercontent.com/u/15922119?u=09066c94bb56c36c5783387dffe8c8609af450d2&v=4", 89 | :state :comment, 90 | :age 1588687724}], 91 | :repo {:name "cli", :url "https://github.com/exoscale/cli"}} 92 | {:labels [], 93 | :updated 1577256924, 94 | :created 1577255934, 95 | :login "cr-cmd", 96 | :title "Create android.yml", 97 | :status 98 | {:min-oks 2, 99 | :oks 0, 100 | :comments 0, 101 | :changes 0, 102 | :open? true, 103 | :color "yellow", 104 | :counter 2, 105 | :type "oks missing"}, 106 | :avatar "https://avatars2.githubusercontent.com/u/56546916?v=4", 107 | :url "https://github.com/clojure/clojure/pull/91", 108 | :mergeable-state "clean", 109 | :reviews [], 110 | :repo {:name "clojure", :url "https://github.com/cr-cmd/clojure"}} 111 | {:labels [], 112 | :updated 1575238643, 113 | :created 1575238643, 114 | :login "oulkarim", 115 | :title "Update changes.md", 116 | :status 117 | {:min-oks 2, 118 | :oks 0, 119 | :comments 0, 120 | :changes 0, 121 | :open? true, 122 | :color "yellow", 123 | :counter 2, 124 | :type "oks missing"}, 125 | :avatar "https://avatars1.githubusercontent.com/u/28899494?v=4", 126 | :url "https://github.com/clojure/clojure/pull/89", 127 | :mergeable-state "clean", 128 | :reviews [], 129 | :repo {:name "clojure", :url "https://github.com/oulkarim/clojure"}} 130 | {:labels [], 131 | :updated 1572647642, 132 | :created 1572630441, 133 | :login "dheerajbhaskar", 134 | :title "update core.clj - clarify docs for reduce function", 135 | :status 136 | {:min-oks 2, 137 | :oks 0, 138 | :comments 0, 139 | :changes 0, 140 | :open? true, 141 | :color "yellow", 142 | :counter 2, 143 | :type "oks missing"}, 144 | :avatar "https://avatars0.githubusercontent.com/u/2944909?v=4", 145 | :url "https://github.com/clojure/clojure/pull/88", 146 | :mergeable-state "clean", 147 | :reviews [], 148 | :repo 149 | {:name "clojure", :url "https://github.com/dheerajbhaskar/clojure"}} 150 | {:labels [], 151 | :updated 1503419976, 152 | :created 1503419474, 153 | :login "blrhc", 154 | :title "Removed formatting error", 155 | :status 156 | {:min-oks 2, 157 | :oks 0, 158 | :comments 0, 159 | :changes 0, 160 | :open? true, 161 | :color "yellow", 162 | :counter 2, 163 | :type "oks missing"}, 164 | :avatar "https://avatars2.githubusercontent.com/u/4144334?v=4", 165 | :url "https://github.com/clojure/clojure/pull/72", 166 | :mergeable-state "clean", 167 | :reviews [], 168 | :repo {:name "clojure", :url "https://github.com/blrhc/clojure"}} 169 | {:labels [], 170 | :updated 1491264474, 171 | :created 1491255400, 172 | :login "robatron", 173 | :title "Define the arguments of reduce’s callback function", 174 | :status 175 | {:min-oks 2, 176 | :oks 0, 177 | :comments 0, 178 | :changes 0, 179 | :open? true, 180 | :color "yellow", 181 | :counter 2, 182 | :type "oks missing"}, 183 | :avatar "https://avatars2.githubusercontent.com/u/127095?v=4", 184 | :url "https://github.com/clojure/clojure/pull/71", 185 | :mergeable-state "clean", 186 | :reviews [], 187 | :repo {:name "clojure", :url "https://github.com/robatron/clojure"}} 188 | {:labels [], 189 | :updated 1487420690, 190 | :created 1485225685, 191 | :login "ssisksl77", 192 | :title "Update Cons.java", 193 | :status 194 | {:min-oks 2, 195 | :oks 0, 196 | :comments 0, 197 | :changes 0, 198 | :open? true, 199 | :color "yellow", 200 | :counter 2, 201 | :type "oks missing"}, 202 | :avatar "https://avatars2.githubusercontent.com/u/15942102?v=4", 203 | :url "https://github.com/clojure/clojure/pull/70", 204 | :mergeable-state "clean", 205 | :reviews [], 206 | :repo {:name nil, :url nil}} 207 | {:labels [], 208 | :updated 1478278312, 209 | :created 1443736493, 210 | :login "eliudiaz", 211 | :title "1.1.x", 212 | :status 213 | {:min-oks 2, 214 | :oks 0, 215 | :comments 0, 216 | :changes 0, 217 | :open? true, 218 | :color "yellow", 219 | :counter 2, 220 | :type "oks missing"}, 221 | :avatar "https://avatars3.githubusercontent.com/u/1623354?v=4", 222 | :url "https://github.com/clojure/clojure/pull/58", 223 | :mergeable-state "dirty", 224 | :reviews [], 225 | :repo {:name "clojure", :url "https://github.com/clojure/clojure"}} 226 | {:labels [], 227 | :updated 1440184517, 228 | :created 1421768960, 229 | :login "kworam", 230 | :title "step must be positive", 231 | :status 232 | {:min-oks 2, 233 | :oks 0, 234 | :comments 0, 235 | :changes 0, 236 | :open? true, 237 | :color "yellow", 238 | :counter 2, 239 | :type "oks missing"}, 240 | :avatar "https://avatars0.githubusercontent.com/u/1706841?v=4", 241 | :url "https://github.com/clojure/clojure/pull/51", 242 | :mergeable-state "clean", 243 | :reviews [], 244 | :repo {:name "clojure", :url "https://github.com/kworam/clojure"}}] 245 | -------------------------------------------------------------------------------- /html/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/pullq/a1f4ed408cda61ac1454f5e7cf60b2f514702765/html/img/favicon.png -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject exoscale/pullq "0.3.0" 2 | :dependencies [[org.clojure/clojure "1.10.2-alpha1"] 3 | [org.clojure/clojurescript "1.10.773"] 4 | [org.clojure/tools.cli "1.0.194"] 5 | [cljsjs/moment "2.24.0-0"] 6 | [day8.re-frame/http-fx "v0.2.0"] 7 | [irresponsible/tentacles "0.6.6"] 8 | [clj-time "0.15.2"] 9 | [reagent "0.9.1"] 10 | [re-frame "0.12.0"] 11 | [soda-ash "0.83.0"]] 12 | :plugins [[lein-cljsbuild "1.1.8"]] 13 | :uberjar-name "pullq.jar" 14 | :main pullq.main 15 | :min-lein-version "2.5.3" 16 | :source-paths ["src/clj" "src/cljs"] 17 | :clean-targets ^{:protect false} ["resources/public/js/compiled" "target"] 18 | :profiles {:uberjar {:aot :all}} 19 | :cljsbuild {:builds 20 | [{:id "min" 21 | :source-paths ["src/cljs"] 22 | :compiler {:main pullq.main 23 | :output-to "resources/public/js/compiled/app.js" 24 | :optimizations :advanced 25 | :closure-defines {goog.DEBUG false} 26 | :pretty-print false}}]}) 27 | -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/clj/pullq/main.clj: -------------------------------------------------------------------------------- 1 | (ns pullq.main 2 | (:gen-class) 3 | (:require [clojure.string :as str] 4 | [clojure.java.io :as io] 5 | [clojure.pprint :refer [pprint]] 6 | [clojure.tools.cli :refer [cli]] 7 | [clj-time.format :refer [parse]] 8 | [clj-time.coerce :refer [to-epoch]] 9 | [tentacles.pulls :refer [specific-pull pulls]] 10 | [tentacles.core :refer [api-call]])) 11 | 12 | (defn reviews 13 | [user repo pull & [options]] 14 | (let [resp (api-call :get "repos/%s/%s/pulls/%s/reviews" 15 | [user repo pull] options)] 16 | (when (< (:status resp 200) 300) 17 | resp))) 18 | 19 | (def states 20 | {"COMMENTED" :comment 21 | "APPROVED" :approved 22 | "CHANGES_REQUESTED" :needs-changes}) 23 | 24 | (defn sanitize-review 25 | [{:keys [user state html_url submitted_at] :or {state "unknown"} :as input}] 26 | {:login (:login user) 27 | :url html_url 28 | :avatar (:avatar_url user) 29 | :state (or (get states state) (keyword (str/lower-case state))) 30 | :age (to-epoch (parse submitted_at))}) 31 | 32 | (defn aggregate-reviews 33 | "Create an aggregate review for a user given all of a user's reviews. 34 | 35 | We take the latest review, but override its :state with the latest non-comment 36 | review's :state if there is one. That prevents having somebody approve a review 37 | then cancel the approval by commenting further." 38 | [reviews] 39 | (let [sorted (sort-by :age reviews) 40 | latest (last sorted) 41 | non-comment (filter #(#{:approved :needs-changes} (:state %1)) sorted) 42 | latest-non-comment (last non-comment)] 43 | (assoc latest :state (:state latest-non-comment :comment)))) 44 | 45 | (defn pull-reviews 46 | [raw-reviews] 47 | (some->> raw-reviews 48 | (remove #(= (:state %) "PENDING")) 49 | (map sanitize-review) 50 | (group-by :login) 51 | (reduce-kv #(conj %1 (aggregate-reviews %3)) []))) 52 | 53 | (defn review-stats 54 | [reviews min-oks] 55 | (let [states (mapv :state reviews) 56 | oks (count (filter #{:approved} states)) 57 | changes (count (filter #{:needs-changes} states)) 58 | comments (count (filter #{:comment} states)) 59 | color (cond (pos? changes) "red")] 60 | {:min-oks min-oks 61 | :oks oks 62 | :comments comments 63 | :changes changes 64 | :open? (or (pos? changes) (< oks min-oks)) 65 | :color (cond 66 | (pos? changes) "red" 67 | (< oks min-oks) "yellow" 68 | :else "blue") 69 | :counter (cond 70 | (pos? changes) changes 71 | (< oks min-oks) (- min-oks oks) 72 | :else oks) 73 | :type (cond 74 | (= 1 changes) "blocker" 75 | (pos? changes) "blockers" 76 | (= 1 (- min-oks oks)) "ok missing" 77 | (< oks min-oks) "oks missing" 78 | :else "ready")})) 79 | 80 | (defn pull-stats 81 | [auth min-oks {:keys [labels number title draft] :as pull}] 82 | (let [updated (:updated_at pull) 83 | created (:created_at pull) 84 | login (get-in pull [:user :login]) 85 | repo (get-in pull [:head :repo :name]) 86 | user (get-in pull [:head :repo :owner :login]) 87 | raw-reviews (reviews user repo number auth) 88 | reviews (pull-reviews raw-reviews)] 89 | {:repo {:name (get-in pull [:head :repo :name]) 90 | :url (get-in pull [:head :repo :html_url])} 91 | :url (:html_url pull) 92 | :labels (mapv :name labels) 93 | :title title 94 | :draft draft 95 | :updated (to-epoch (parse updated)) 96 | :created (to-epoch (parse created)) 97 | :login login 98 | :avatar (get-in pull [:user :avatar_url]) 99 | :reviews (vec (sort-by :age reviews)) 100 | :status (review-stats reviews min-oks)})) 101 | 102 | (defn pulls-with-details 103 | [user repo auth] 104 | (map (fn [{:keys [number] :as pull}] 105 | (let [details (specific-pull user repo number auth)] 106 | (merge details pull))) 107 | (pulls user repo auth))) 108 | 109 | (defn pull-fn 110 | [auth] 111 | (fn [[user repo min-oks]] 112 | (->> (pulls-with-details user repo auth) 113 | (remove #(:draft %)) 114 | (map (partial pull-stats auth min-oks))))) 115 | 116 | (defn pull-queue 117 | [auth config] 118 | (vec 119 | (mapcat (pull-fn auth) config))) 120 | 121 | (def valid-conf 122 | #"^([A-Za-z0-9-]+)[ \t]+([A-Za-z0-9-\.]+)[ \t]+([0-9]+).*") 123 | 124 | (defn read-config 125 | [path] 126 | (try 127 | (for [raw (line-seq (io/reader path)) 128 | :let [[_ user repo oks] (re-matches valid-conf raw)] 129 | :when (some? user)] 130 | [user repo (Long/parseLong oks)]) 131 | (catch Exception e 132 | (binding [*out* *err*] 133 | (println "could not parse config" path ":" (.getMessage e)) 134 | (System/exit 1))))) 135 | 136 | (defn get-cli 137 | [args] 138 | (try 139 | (cli args 140 | ["-h" "--help" "Show Help" :default false :flag true] 141 | ["-t" "--token" "Github Token, overrides GITHUB_TOKEN environment"] 142 | ["-o" "--output" "Where to dump data" :default "build/data.edn"] 143 | ["-S" "--syncdir" "A directory in which to produce a full static site"] 144 | ["-f" "--path" "Configuration file path" :default "pullq.conf"]) 145 | (catch Exception _ 146 | (binding [*out* *err*] 147 | (println "could not parse arguments") 148 | (System/exit 1))))) 149 | 150 | (def files 151 | ["data.edn" 152 | "index.html" 153 | "css/themes/default/assets/fonts/icons.eot" 154 | "css/themes/default/assets/fonts/icons.otf" 155 | "css/themes/default/assets/fonts/icons.ttf" 156 | "css/themes/default/assets/fonts/icons.svg" 157 | "css/themes/default/assets/fonts/icons.woff" 158 | "css/themes/default/assets/fonts/icons.woff2" 159 | "css/themes/default/assets/images/flags.png" 160 | "css/semantic.min.css" 161 | "img/favicon.png" 162 | "js/compiled/app.js"]) 163 | 164 | (defn copy-files 165 | [syncdir] 166 | (doseq [path files 167 | :let [src (io/file "build" path) 168 | dst (io/file syncdir path)]] 169 | (io/make-parents dst) 170 | (io/copy src dst))) 171 | 172 | (defn -main 173 | [& args] 174 | (let [[opts _ banner] (get-cli args) 175 | config (read-config (:path opts)) 176 | env-token (System/getenv "GITHUB_TOKEN") 177 | auth {:oauth-token (or (:token opts) env-token) 178 | :per-page 100}] 179 | (when (:help opts) 180 | (println "Usage: pullq [-t token] [-f config] [-o outfile] [-S syncdir]\n") 181 | (print banner) 182 | (flush) 183 | (System/exit 0)) 184 | (try 185 | (println "starting dump, this might take a while") 186 | (spit (:output opts) (with-out-str (pprint (pull-queue auth config)))) 187 | (println "created data file in:" (:output opts)) 188 | (when-some [syncdir (:syncdir opts)] 189 | (println "copying full website output to:" syncdir) 190 | (copy-files syncdir)) 191 | (catch Exception e 192 | (binding [*out* *err*] 193 | (println "could not generate stats:" (.getMessage e)) 194 | (System/exit 1)))))) 195 | 196 | (comment 197 | (def auth {}) 198 | (def config [["pyr" "dot.emacs" 2] ["pyr" "watchman" 2]]) 199 | 200 | ;; (pulls-with-details "pyr" "dot.emacs" auth) 201 | (pull-queue auth config)) 202 | -------------------------------------------------------------------------------- /src/cljs/pullq/config.cljs: -------------------------------------------------------------------------------- 1 | (ns pullq.config) 2 | 3 | (def debug? 4 | ^boolean goog.DEBUG) 5 | -------------------------------------------------------------------------------- /src/cljs/pullq/db.cljs: -------------------------------------------------------------------------------- 1 | (ns pullq.db) 2 | 3 | (def default-db 4 | {:only {:author nil :repo nil} 5 | :hidden-labels ["wip" "hold" "content"] 6 | :filter :open 7 | :order :updated 8 | :sort-dir < 9 | :search "" 10 | :pulls []}) 11 | -------------------------------------------------------------------------------- /src/cljs/pullq/events.cljs: -------------------------------------------------------------------------------- 1 | (ns pullq.events 2 | (:require 3 | [re-frame.core :as re-frame] 4 | [ajax.edn :as edn] 5 | [pullq.db :as db])) 6 | 7 | (re-frame/reg-event-db 8 | ::initialize-db 9 | (fn [_ _] db/default-db)) 10 | 11 | 12 | (re-frame/reg-event-db 13 | ::set-order 14 | (fn [db [_ value]] (assoc db :order value))) 15 | 16 | (re-frame/reg-event-db 17 | ::set-sort-dir 18 | (fn [db [_ value]] (assoc db :sort-dir value))) 19 | 20 | (re-frame/reg-event-db 21 | ::set-filter 22 | (fn [db [_ value]] (assoc db :filter value))) 23 | 24 | (re-frame/reg-event-db 25 | ::set-search 26 | (fn [db [_ value]] (assoc db :search value))) 27 | 28 | (re-frame/reg-event-db 29 | ::toggle 30 | (fn 31 | [db [_ what value]] 32 | (let [current (get-in db [:only what])] 33 | (assoc-in db [:only what] (if (= current value) nil value))))) 34 | 35 | (re-frame/reg-event-db 36 | ::refresh-success 37 | (fn 38 | [db [_ value]] 39 | (assoc db :pulls value))) 40 | 41 | (re-frame/reg-event-db 42 | ::refresh-failure 43 | (fn 44 | [db [_ {:keys [debug-message]}]] 45 | (assoc db :error debug-message))) 46 | 47 | (re-frame/reg-event-fx 48 | ::refresh-db 49 | (fn 50 | [{:keys [db]} _] 51 | {:db (assoc db :pulling? true) 52 | :http-xhrio {:method :get 53 | :uri "data.edn" 54 | :timeout 8000 55 | :response-format (edn/edn-response-format) 56 | :on-success [::refresh-success] 57 | :on-failure [::refresh-failure]}})) 58 | 59 | 60 | (re-frame/reg-event-db 61 | ::hide-label 62 | (fn 63 | [db [_ label]] 64 | (update db :hidden-labels conj label))) 65 | 66 | (re-frame/reg-event-db 67 | ::show-label 68 | (fn 69 | [db [_ label]] 70 | (enable-console-print!) 71 | (println "showing label: " (pr-str label)) 72 | (update db :hidden-labels #(remove (partial = label) %)))) 73 | -------------------------------------------------------------------------------- /src/cljs/pullq/main.cljs: -------------------------------------------------------------------------------- 1 | (ns pullq.main 2 | (:require 3 | [reagent.core :as reagent] 4 | [re-frame.core :as re-frame] 5 | [pullq.events :as events] 6 | [pullq.views :as views] 7 | [pullq.config :as config] 8 | [day8.re-frame.http-fx])) 9 | 10 | 11 | (defn dev-setup [] 12 | (when config/debug? 13 | (enable-console-print!) 14 | (println "dev mode"))) 15 | 16 | (defn mount-root [] 17 | (re-frame/clear-subscription-cache!) 18 | (reagent/render [views/main-panel] 19 | (.getElementById js/document "app"))) 20 | 21 | (defn ^:export init [] 22 | (re-frame/dispatch-sync [::events/initialize-db]) 23 | (re-frame/dispatch-sync [::events/refresh-db]) 24 | (dev-setup) 25 | (mount-root)) 26 | -------------------------------------------------------------------------------- /src/cljs/pullq/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns pullq.subs 2 | (:require 3 | [re-frame.core :as re-frame] 4 | [clojure.string :as str])) 5 | 6 | (defn is-open? 7 | [pull] 8 | (get-in pull [:status :open?])) 9 | 10 | (defn bug? 11 | [{:keys [labels]}] 12 | (boolean (seq (filter #(str/includes? % "bug") labels)))) 13 | 14 | (defn match-fn 15 | [pattern] 16 | (fn [{:keys [title repo]}] 17 | (when (and (some? title) (some? repo)) 18 | (let [p (re-pattern pattern)] 19 | (or (re-find p title) (re-find p (:name repo))))))) 20 | 21 | (defn only-fn 22 | [{:keys [author repo]}] 23 | (fn [pull] 24 | (and (or (nil? author) (= author (:login pull))) 25 | (or (nil? repo) (= repo (get-in pull [:repo :name])))))) 26 | 27 | (defn sort-fn 28 | [order] 29 | (if (= :age order) :created :updated)) 30 | 31 | (defn hidden-label-fn 32 | [hidden-labels] 33 | (fn [{:keys [labels]}] 34 | (first 35 | (for [hidden hidden-labels 36 | :let [found? (some #(str/includes? % hidden) (map str/lower-case labels))] 37 | :when found?] 38 | true)))) 39 | 40 | (defn filter-pulls 41 | [db] 42 | (->> (:pulls db) 43 | (filter (match-fn (:search db))) 44 | (filter (only-fn (:only db))) 45 | (remove (hidden-label-fn (:hidden-labels db))) 46 | (sort-by (sort-fn (:order db)) (:sort-dir db)))) 47 | 48 | (re-frame/reg-sub 49 | ::pulls 50 | (fn [db] 51 | (let [filter-name (:filter db)] 52 | (filter (cond 53 | (= :open filter-name) is-open? 54 | (= :bug filter-name) bug? 55 | :else (complement is-open?)) 56 | (filter-pulls db))))) 57 | 58 | (defn get-authors 59 | [pulls] 60 | (distinct 61 | (for [{:keys [avatar login]} pulls] 62 | [login avatar]))) 63 | 64 | (defn get-repos 65 | [pulls] 66 | (distinct 67 | (for [{:keys [repo]} pulls] 68 | (:name repo)))) 69 | 70 | (re-frame/reg-sub 71 | ::menu-stats 72 | (fn [db] 73 | (let [pulls (filter-pulls db) 74 | open (count (filter is-open? pulls))] 75 | {:open open 76 | :bug (count (filter bug? pulls)) 77 | :ready (- (count pulls) open) 78 | :repos (get-repos pulls) 79 | :authors (get-authors pulls) 80 | :filter (:filter db) 81 | :hidden-labels (:hidden-labels db) 82 | :order (:order db) 83 | :sort-dir (:sort-dir db) 84 | :search (:search db) 85 | :only (:only db)}))) 86 | 87 | (re-frame/reg-sub ::order :order) 88 | (re-frame/reg-sub ::hidden-labels :hidden-labels) 89 | -------------------------------------------------------------------------------- /src/cljs/pullq/views.cljs: -------------------------------------------------------------------------------- 1 | (ns pullq.views 2 | (:require 3 | [clojure.string :as str] 4 | [re-frame.core :as re-frame] 5 | [pullq.events :as events] 6 | [pullq.subs :as subs] 7 | [soda-ash.core :as sa] 8 | [reagent.core :as reagent] 9 | [cljsjs.moment])) 10 | 11 | (def exoscale-logo 12 | "https://www.exoscale.com/static/img/logo-exoscale-white-201711.svg") 13 | 14 | (def enter-key 15 | 13) 16 | 17 | (defn epoch->age 18 | [epoch] 19 | (.fromNow (js/moment (* 1000 epoch)))) 20 | 21 | (defn header-menu 22 | [] 23 | [sa/Menu {:inverted true} 24 | [sa/Container 25 | [sa/MenuItem 26 | [sa/Image {:size "small" :src exoscale-logo}]] 27 | [sa/MenuItem "Pull Request Queue"]]]) 28 | 29 | (defn repo-entry 30 | [repo only] 31 | [sa/MenuItem {:on-click #(re-frame/dispatch [::events/toggle :repo repo]) 32 | :active (= only repo) 33 | :as "a"} 34 | repo]) 35 | 36 | (defn author-entry 37 | [[login avatar] only] 38 | [sa/MenuItem {:on-click #(re-frame/dispatch [::events/toggle :author login]) 39 | :active (= only login) 40 | :as "a"} 41 | [:div 42 | [sa/Image {:avatar true :size "mini" :src avatar}] 43 | [:span login]]]) 44 | 45 | (defn hidden-label-menu 46 | [] 47 | (let [input (reagent/atom "") 48 | hidden-labels (re-frame/subscribe [::subs/hidden-labels])] 49 | (fn [] 50 | [sa/MenuItem 51 | [sa/MenuHeader "Hidden Labels"] 52 | (into 53 | [sa/MenuMenu 54 | [sa/MenuItem 55 | [sa/Input 56 | {:icon "filter" 57 | :placeholder "Hide label..." 58 | :value @input 59 | :on-change #(reset! input (-> % .-target .-value)) 60 | :on-key-press (fn [e] 61 | (when (= enter-key (.-charCode e)) 62 | (let [val @input] 63 | (reset! input "") 64 | (re-frame/dispatch 65 | [::events/hide-label val]))))}]]] 66 | (for [label @hidden-labels] 67 | [sa/MenuItem label [sa/Icon {:on-click #(re-frame/dispatch 68 | [::events/show-label label]) 69 | :name "delete"}]]))]))) 70 | 71 | (def filter-colors 72 | {:bug "red" 73 | :open "yellow" 74 | :ready "blue"}) 75 | 76 | (defn filter-counter 77 | [current-filter filter count] 78 | [sa/MenuItem 79 | {:as "a" 80 | :active (= current-filter filter) 81 | :on-click #(re-frame/dispatch [::events/set-filter filter])} 82 | [sa/Label {:class (when (pos? count) (get filter-colors filter))} count] 83 | (-> filter name str/capitalize)]) 84 | 85 | (defn right-menu 86 | [] 87 | (let [stats (re-frame/subscribe [::subs/menu-stats])] 88 | (fn [] 89 | (let [{:keys [filter search open bug ready repos sort-dir order authors only]} @stats] 90 | [:div 91 | [sa/Menu {:vertical true :fluid true} 92 | [filter-counter filter :bug bug] 93 | [filter-counter filter :open open] 94 | [filter-counter filter :ready ready] 95 | [sa/MenuItem 96 | [sa/Input 97 | {:icon "search" 98 | :placeholder "Search pull requests..." 99 | :on-change #(re-frame/dispatch [::events/set-search 100 | (-> % .-target .-value)]) 101 | :value search}]]] 102 | [sa/Menu {:vertical true :fluid true} 103 | [sa/MenuItem 104 | {:as "a"} 105 | "Refresh" 106 | [sa/Icon {:name "refresh" 107 | :on-click #(re-frame/dispatch [::events/refresh-db])}]] 108 | [sa/MenuItem 109 | [sa/MenuHeader "Sort Order"] 110 | [sa/MenuMenu 111 | [sa/MenuItem 112 | {:as "a" 113 | :active (= order :age) 114 | :on-click #(re-frame/dispatch [::events/set-order :age])} 115 | "Age"] 116 | [sa/MenuItem 117 | {:as "a" 118 | :active (= order :updated) 119 | :on-click #(re-frame/dispatch [::events/set-order :updated])} 120 | "Updated"]]] 121 | 122 | [sa/MenuItem 123 | [sa/MenuHeader "Sort Direction"] 124 | [sa/MenuMenu 125 | [sa/MenuItem 126 | {:as "a" 127 | :active (= sort-dir <) 128 | :on-click #(re-frame/dispatch [::events/set-sort-dir <])} 129 | "Ascending"] 130 | [sa/MenuItem 131 | {:as "a" 132 | :active (= sort-dir >) 133 | :on-click #(re-frame/dispatch [::events/set-sort-dir >])} 134 | "Descending"]]] 135 | 136 | [hidden-label-menu] 137 | [sa/MenuItem 138 | [sa/MenuHeader "Repos"] 139 | (into [sa/MenuMenu] (map repo-entry repos (repeat (:repo only))))] 140 | [sa/MenuItem 141 | [sa/MenuHeader "Authors"] 142 | (into [sa/MenuMenu] (map author-entry authors (repeat (:author only))))]]])))) 143 | 144 | (defn state->icon 145 | [state] 146 | (get {:comment "discussions" 147 | :needs-changes "delete" 148 | :approved "checkmark"} 149 | state 150 | "help")) 151 | 152 | (defn review-entry 153 | [{:keys [avatar state url]}] 154 | [sa/ListItem {:as "a" :href url} 155 | [sa/Image {:avatar true :src avatar}] 156 | [sa/ListContent 157 | [sa/ListHeader [sa/Icon {:name (state->icon state)}]]]]) 158 | 159 | (defn request-row 160 | [{:keys [repo avatar url title status reviews created status updated login labels]}] 161 | [sa/TableRow 162 | [sa/TableCell [sa/CommentGroup 163 | [sa/CommentSA 164 | [sa/CommentAvatar {:src avatar :size "mini" :alt login}] 165 | [sa/CommentContent 166 | [sa/CommentAuthor {:as "a" :href (:url repo)} 167 | (:name repo)] 168 | [sa/CommentMetadata [:div (epoch->age created)]] 169 | [sa/CommentText 170 | [:a {:href url} title]]]]]] 171 | [sa/TableCell 172 | (into [sa/ListSA {:size "mini" :horizontal true :divided true}] 173 | (map review-entry reviews))] 174 | [sa/TableCell 175 | (let [{:keys [open? counter type color]} status 176 | button-opts {:color color 177 | :compact true 178 | :as "div" 179 | :fluid true 180 | :size "mini"} 181 | type-button [sa/Button button-opts 182 | [:small type]]] 183 | [:div 184 | (if open? 185 | [sa/Button {:as "div" :label-position "right" :fluid true} 186 | [sa/Label {:basic true :pointing "right"} [:small (str counter)]] 187 | type-button] 188 | type-button) 189 | [:div {:style {:color "grey"}} 190 | [:small (epoch->age updated)]]])]]) 191 | 192 | (defn request-table 193 | [] 194 | (let [pulls (re-frame/subscribe [::subs/pulls])] 195 | (fn [] 196 | [sa/Table {} 197 | [sa/TableHeader 198 | [sa/TableRow 199 | [sa/TableHeaderCell "Title"] 200 | [sa/TableHeaderCell "Reviewers"] 201 | [sa/TableHeaderCell "Status"]]] 202 | (into [sa/TableBody] (map request-row @pulls))]))) 203 | 204 | (defn main-panel [] 205 | [:div 206 | [header-menu] 207 | [sa/Container 208 | [sa/Grid 209 | [sa/GridRow 210 | [sa/GridColumn {:width 13} [request-table]] 211 | [sa/GridColumn {:width 3} [right-menu]]]]]]) 212 | -------------------------------------------------------------------------------- /test/pullq/test_main.clj: -------------------------------------------------------------------------------- 1 | (ns pullq.test-main 2 | (:require [clojure.test :refer :all] 3 | [pullq.main :refer :all])) 4 | 5 | (defn make-raw-review 6 | [username time state] 7 | {:state state 8 | :html_url "https://example.com" 9 | :submitted_at "2019-02-15T10:50:34Z" 10 | :user {:login username 11 | :avatar_url (format "https://example.com/avatar/%s" username)}}) 12 | 13 | (deftest pull-reviews-test 14 | (testing "approving overrides needs fixing" 15 | (is (= :approved 16 | (:state (first (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "CHANGES_REQUESTED") 17 | (make-raw-review "testuser" "2019-02-15T10:50:35Z" "APPROVED")])))))) 18 | 19 | (testing "needs fixing overrides approvals" 20 | (is (= :needs-changes 21 | (:state (first (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "APPROVED") 22 | (make-raw-review "testuser" "2019-02-15T10:50:35Z" "CHANGES_REQUESTED")])))))) 23 | 24 | (testing "comment-only reviews count as commenting" 25 | (is (= :comment 26 | (:state (first (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "COMMENTED") 27 | (make-raw-review "testuser" "2019-02-15T10:50:35Z" "COMMENTED")])))))) 28 | 29 | (testing "commenting is overwritten by approvals" 30 | (is (= :approved 31 | (:state (first (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "COMMENTED") 32 | (make-raw-review "testuser" "2019-02-15T10:50:35Z" "APPROVED")])))))) 33 | 34 | (testing "commenting is overwritten by needs fixing" 35 | (is (= :needs-changes 36 | (:state (first (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "COMMENTED") 37 | (make-raw-review "testuser" "2019-02-15T10:50:35Z" "CHANGES_REQUESTED")])))))) 38 | 39 | (testing "commenting doesn't override approvals" 40 | (is (= :approved 41 | (:state (first (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "APPROVED") 42 | (make-raw-review "testuser" "2019-02-15T10:50:35Z" "COMMENTED")])))))) 43 | 44 | (testing "commenting doesn't override needs fixing" 45 | (is (= :needs-changes 46 | (:state (first (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "CHANGES_REQUESTED") 47 | (make-raw-review "testuser" "2019-02-15T10:50:35Z" "COMMENTED")])))))) 48 | 49 | (testing "multiple reviews from multiple users" 50 | (let [result (pull-reviews [(make-raw-review "testuser" "2019-02-15T10:50:34Z" "APPROVED") 51 | (make-raw-review "testuser2" "2019-02-15T10:50:35Z" "CHANGES_REQUESTED") 52 | (make-raw-review "testuser" "2019-02-15T10:51:35Z" "CHANGES_REQUESTED") 53 | (make-raw-review "testuser" "2019-02-15T10:51:35Z" "COMMENTED") 54 | (make-raw-review "testuser" "2019-02-15T10:52:35Z" "APPROVED") 55 | (make-raw-review "testuser2" "2019-02-15T10:52:33Z" "APPROVED") 56 | (make-raw-review "testuser3" "2019-02-15T10:52:33Z" "COMMENTED") 57 | (make-raw-review "testuser3" "2019-02-15T10:52:33Z" "APPROVED") 58 | (make-raw-review "testuser3" "2019-02-15T10:52:33Z" "COMMENTED")])] 59 | 60 | (is (= 3 (count result))) 61 | (is (= [:approved :approved :approved] (map :state result)))))) 62 | --------------------------------------------------------------------------------