├── .gitignore ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── packages ├── platforms ├── release └── versions ├── README.md ├── client ├── hubble.css ├── hubble.html ├── issues │ ├── comments.css │ ├── comments.html │ ├── comments.js │ ├── issue.css │ ├── issue.html │ ├── issue.js │ └── models.js ├── labels │ ├── label.css │ ├── label.html │ └── label.js ├── lib │ └── helpers.js ├── main-page │ ├── main.css │ ├── main.html │ └── main.js └── navbar │ ├── navbar.css │ ├── navbar.html │ └── navbar.js ├── packages └── hubble:issue-sync │ ├── .npm │ └── package │ │ ├── .gitignore │ │ ├── README │ │ └── npm-shrinkwrap.json │ ├── README.md │ ├── async.js │ ├── claim.js │ ├── classify.js │ ├── client.js │ ├── config_server.js │ ├── hubble:issue-sync-tests.js │ ├── match.js │ ├── package.js │ ├── sync_server.js │ └── team.js ├── public └── theme.jpg ├── publish.js ├── routes.js └── server └── auth.js /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 1q95zmslh4a3k1xtb3ch 8 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-platform 8 | hubble:issue-sync 9 | twbs:bootstrap 10 | iron:router 11 | accounts-ui 12 | accounts-github 13 | audit-argument-checks 14 | force-ssl 15 | browser-policy 16 | glasser:reactive-fromnow@=0.0.1 17 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.1.0.2 2 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.0 2 | accounts-github@1.0.4 3 | accounts-oauth@1.1.5 4 | accounts-ui@1.1.5 5 | accounts-ui-unstyled@1.1.7 6 | audit-argument-checks@1.0.3 7 | autoupdate@1.2.1 8 | base64@1.0.3 9 | binary-heap@1.0.3 10 | blaze@2.1.2 11 | blaze-tools@1.0.3 12 | boilerplate-generator@1.0.3 13 | browser-policy@1.0.4 14 | browser-policy-common@1.0.3 15 | browser-policy-content@1.0.4 16 | browser-policy-framing@1.0.4 17 | callback-hook@1.0.3 18 | check@1.0.5 19 | ddp@1.1.0 20 | deps@1.0.7 21 | ejson@1.0.6 22 | fastclick@1.0.3 23 | force-ssl@1.0.4 24 | geojson-utils@1.0.3 25 | github@1.1.3 26 | glasser:reactive-fromnow@0.0.1 27 | html-tools@1.0.4 28 | htmljs@1.0.4 29 | http@1.1.0 30 | hubble:issue-sync@0.0.1 31 | id-map@1.0.3 32 | iron:controller@1.0.7 33 | iron:core@1.0.7 34 | iron:dynamic-template@1.0.7 35 | iron:layout@1.0.7 36 | iron:location@1.0.7 37 | iron:middleware-stack@1.0.7 38 | iron:router@1.0.7 39 | iron:url@1.0.7 40 | jquery@1.11.3_2 41 | json@1.0.3 42 | launch-screen@1.0.2 43 | less@1.0.14 44 | livedata@1.0.13 45 | localstorage@1.0.3 46 | logging@1.0.7 47 | meteor@1.1.6 48 | meteor-platform@1.2.2 49 | minifiers@1.1.5 50 | minimongo@1.0.8 51 | mobile-status-bar@1.0.3 52 | momentjs:moment@2.9.0 53 | mongo@1.1.0 54 | oauth@1.1.4 55 | oauth2@1.1.3 56 | observe-sequence@1.0.6 57 | ordered-dict@1.0.3 58 | random@1.0.3 59 | reactive-dict@1.1.0 60 | reactive-var@1.0.5 61 | reload@1.1.3 62 | retry@1.0.3 63 | routepolicy@1.0.5 64 | service-configuration@1.0.4 65 | session@1.1.0 66 | spacebars@1.0.6 67 | spacebars-compiler@1.0.6 68 | templating@1.1.1 69 | tracker@1.0.7 70 | twbs:bootstrap@3.3.2 71 | ui@1.0.6 72 | underscore@1.0.3 73 | url@1.0.4 74 | webapp@1.2.0 75 | webapp-hashing@1.0.3 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archival 2 | This repo was archived by the Apollo Security team on 2023-05-26 3 | 4 | Please reach out at security@apollographql.com with questions 5 | 6 | 7 | # githubble 8 | 9 | A tool for triaging GitHub issues. 10 | 11 | ## Dev setup 12 | 13 | You can just run it with Meteor run... but you really want a GitHub access token 14 | so that you can make 5000 API queries/hr instead of 60. And if you want 15 | immediate updates, you have to set up webhooks too. 16 | 17 | ### GitHub access token 18 | 19 | Go to https://github.com/settings/applications and create a "personal access 20 | token" with **NO SCOPES** (ie, *UNCHECK ALL THE SCOPE BOXES*). That will give 21 | you a hex string which is your access token. Put it in the `$GITHUB_TOKEN` 22 | environment variable when running Meteor locally. Note that GitHub will only 23 | give you this token once; it's your job to remember it (or make a new one). 24 | 25 | ### GitHub webhook setup 26 | 27 | First, you'll want to expose your local Meteor app to the internet (exciting!) 28 | 29 | Download and install ngrok from https://ngrok.com/ 30 | 31 | Run it as `ngrok 3000`: this will create a tunnel from a subdomain of ngrok.com 32 | to your localhost:3000, and print the link. (You may want to make sure you have 33 | a dark background for your terminal.) 34 | 35 | Now set up a webhook that points to this URL. Go to https://github.com/meteor/meteor/settings/hooks and click "Add webhook". 36 | 37 | - Set the payload URL to https://whateverngroksaid.ngrok.com/webhook" 38 | - Keep Content type as `application/json` 39 | - Set the secret to a random string (eg `openssl rand -hex 20`), which you should 40 | also set in `$GITHUB_WEBHOOK_SECRET` when you run it (this part is optional 41 | for local dev, but a good idea since otherwise anyone on the internet can 42 | send you webhooks and insert random stuff into your database) 43 | - "Let me select individual events", and choose three events: Issues, Issue 44 | Comment, and Pull request 45 | - "Add webhook" 46 | 47 | ## Production setup 48 | 49 | Uses a Compose (formerly MongoHQ) MongoDB 2.6 installation, so that we get 2.6 50 | and oplog access. Log in to https://app.compose.io/ with the username/password 51 | in LastPass (under mongohq). It's the githubble deployment. I created a database 52 | called githubble, deleted the default user, and created another user in it with 53 | a random password. 54 | 55 | I generated a random string for the webhook secret and a personal access token 56 | (for glasser) for the token. 57 | 58 | The token, secret, and Mongo URLs are in a settings.json in LastPass (we use 59 | settings instead of environment variables). So you don't need to specify 60 | `--settings` on every deploy. 61 | 62 | Note that the oplog URL needs to be on the `local` database (ie the URL path is 63 | `local`) and specify `&authSource=githubble` (ie it gets its authentication from 64 | the main `githubble` database). 65 | 66 | Note also that githubble now runs on Galaxy under the mdg account. `settings.json` can be found in Dropbox, at the date of writing at `/galaxy-prod/keys/Githubble`. 67 | -------------------------------------------------------------------------------- /client/hubble.css: -------------------------------------------------------------------------------- 1 | /* CSS declarations go here */ 2 | .avatar { 3 | height: 50px; 4 | width: 50px; 5 | padding: 2px; 6 | } 7 | 8 | .main { 9 | width: 100vw; 10 | height: 100vh; 11 | background: #004; 12 | } 13 | 14 | .buffer { 15 | height: 3em; 16 | } 17 | 18 | .user { 19 | text-align:center; 20 | } 21 | 22 | body { 23 | background-image: url(theme.jpg); 24 | 25 | } 26 | -------------------------------------------------------------------------------- /client/hubble.html: -------------------------------------------------------------------------------- 1 | 2 | hubble 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 27 | 28 | 38 | -------------------------------------------------------------------------------- /client/issues/comments.css: -------------------------------------------------------------------------------- 1 | .comment-view { 2 | width: 95%; 3 | margin-left: 3em; 4 | background-color: #F5F6CE; 5 | border-bottom-right-radius: 1em; 6 | border-bottom-left-radius: 1em; 7 | box-shadow: 5px 5px 5px #000; 8 | } 9 | 10 | .comment-box { 11 | padding-top: 3em; 12 | padding-right: 3em; 13 | padding-left: 1em; 14 | } 15 | 16 | .comment-panel { 17 | background-color: #F5F6CE; 18 | height: 3em; 19 | margin: 0px; 20 | padding: 0px; 21 | border-bottom-right-radius: 1em; 22 | border-bottom-left-radius: 1em; 23 | box-shadow: 5px 5px 5px #000; 24 | } 25 | 26 | .comment-panel-button { 27 | margin: 2px; 28 | padding-top: 0.5em; 29 | padding-bottom: 0.5em; 30 | padding-right: 1em; 31 | padding-left: 1em; 32 | text-align: center; 33 | box-shadow: 1px 5px 5px #222; 34 | cursor: pointer; 35 | width:100%; 36 | font-weight: bold; 37 | font-family: "Trebuchet MS", Helvetica, sans-serif; 38 | } 39 | 40 | .highly-active { 41 | background: linear-gradient(to right, #F8DCC7, #EEE3D7); 42 | position: absolute; 43 | left: -2px; 44 | bottom: -3em; 45 | border-bottom-left-radius: 1em; 46 | } 47 | 48 | .highly-active:hover { 49 | background: linear-gradient(to right, #FE2EF7, #D095FA); 50 | } 51 | 52 | .snooze { 53 | background: linear-gradient(to right, #EEE3D7, #D5E7D3); 54 | position: absolute; 55 | right: -2px; 56 | bottom: -3em; 57 | } 58 | 59 | 60 | .snooze:hover { 61 | background: linear-gradient(to right, #D095FA, #57AAE9); 62 | } 63 | 64 | .respond { 65 | background: linear-gradient(to right, #D5E7D3, #D5F3C0); 66 | border-bottom-right-radius: 1em; 67 | position: absolute; 68 | right: -2px; 69 | bottom: -3em; 70 | } 71 | 72 | 73 | .respond:hover { 74 | background: linear-gradient(to right, #57AAE9, #57E98A); 75 | } 76 | 77 | .respond a:link { color: #000; } 78 | .respond a:visited { color: #000; } 79 | 80 | .comment { 81 | padding-top: 1em; 82 | } 83 | 84 | .comment-timestamp { 85 | margin-bottom: 0.5em; 86 | } 87 | -------------------------------------------------------------------------------- /client/issues/comments.html: -------------------------------------------------------------------------------- 1 | 2 | 38 | 39 | 40 | 51 | -------------------------------------------------------------------------------- /client/issues/comments.js: -------------------------------------------------------------------------------- 1 | Template.comments.helpers({ 2 | comments: function () { 3 | var self = this; 4 | return _.map(_.keys(self.recentComments || {}).sort(), function (key) { 5 | return self.recentComments[key]; 6 | }); 7 | }, 8 | url: function () { 9 | return this.issueDocument.htmlUrl; 10 | } 11 | }); 12 | 13 | Template.commentText.helpers({ 14 | // We're pretty sure we can trust GitHub's HTML here! If not, switch back from 15 | // {{{bodyHtml}}} to
{{body}}
16 | trustedCommentHtml: function () { 17 | return this.bodyHtml; 18 | }, 19 | color: function () { 20 | return getCommentColor(this.id); 21 | }, 22 | url: function () { 23 | return this.htmlUrl; 24 | }, 25 | }); 26 | 27 | 28 | Template.comments.events({ 29 | "click .snooze": function () { 30 | Meteor.call('snooze', { 31 | repoOwner: this.repoOwner, 32 | repoName: this.repoName, 33 | number: this.issueDocument.number 34 | }); 35 | Session.set(displayId(this), false); 36 | }, 37 | "click .highly-active": function () { 38 | Meteor.call('setHighlyActive', { 39 | repoOwner: this.repoOwner, 40 | repoName: this.repoName, 41 | number: this.issueDocument.number, 42 | highlyActive: ! this.highlyActive 43 | }); 44 | Session.set(displayId(this), false); 45 | } 46 | }); 47 | 48 | Template.comments.onCreated(function () { 49 | this.subscribe('issue-recent-comments', this.data._id); 50 | }); 51 | 52 | var nextColor = 0; 53 | // Get alternating colors for comments. 54 | var getCommentColor = function (seed) { 55 | var colors = 56 | ["F8F4C3", "F8F9DF"]; 57 | nextColor = (nextColor + 1) % colors.length; 58 | return colors[nextColor]; 59 | }; 60 | -------------------------------------------------------------------------------- /client/issues/issue.css: -------------------------------------------------------------------------------- 1 | /* The entire box */ 2 | .issue { 3 | margin: 2em; 4 | } 5 | 6 | /* Top stripe of the issue */ 7 | .issue-stripe { 8 | height: 1.5em; 9 | margin: 0em; 10 | padding: 0.1em; 11 | border-top-left-radius: 0.5em; 12 | border-top-right-radius: 0.5em; 13 | } 14 | 15 | /* Status displayed in the top stripe */ 16 | .status { 17 | font-family: "Trebuchet MS", Helvetica, sans-serif; 18 | font-size: 95%; 19 | color: #efe; 20 | float: left; 21 | border-top-width: 2px; 22 | border-style: solid; 23 | border-color: transparent; 24 | vertical-align: middle; 25 | } 26 | 27 | .claimed-text { 28 | padding-right: 50px; 29 | color: white; 30 | border-size: 5px; 31 | border-radius: 5px; 32 | } 33 | .claim-button, .unclaim-button { 34 | background-color: green; 35 | border-radius: 2px; 36 | border-size: 2px; 37 | border-color: transparent; 38 | float: right; 39 | cursor: pointer; 40 | } 41 | 42 | .unclaim-button { 43 | background-color: red; 44 | } 45 | 46 | .needs-response { 47 | background-color: white; 48 | border-radius: 2px; 49 | border-size: 2px; 50 | border-color: transparent; 51 | float: right; 52 | cursor: pointer; 53 | } 54 | .needs-response:hover { 55 | background-color: blue; 56 | } 57 | 58 | /* Non-project labels in the top stripe */ 59 | .issue-labels { 60 | vertical-align: middle; 61 | margin-right: 10px; 62 | float: right; 63 | } 64 | 65 | /* Main issue body */ 66 | .issue-body { 67 | cursor: pointer; 68 | background-color: #fff; 69 | border-top: 4px; 70 | border-left: 4px; 71 | border-right: 0px; 72 | border-bottom: 0px; 73 | border-style: solid; 74 | border-color: white; 75 | padding: 0px; 76 | margin: 0px; 77 | position: relative; 78 | border-bottom-left-radius: 0.5em; 79 | border-bottom-right-radius: 0.5em; 80 | box-shadow: 1px 5px 5px #333; 81 | } 82 | 83 | /* Title in the issue body */ 84 | .issue-title { 85 | padding: 0.5em; 86 | font-family: "Trebuchet MS", Helvetica, sans-serif; 87 | } 88 | 89 | .issue-label-row { 90 | vertical-align: middle; 91 | margin-right: 10px; 92 | float: left; 93 | } 94 | 95 | /* avatar */ 96 | .avatar { 97 | height: 50px; 98 | width: 50px; 99 | padding: 2px; 100 | } 101 | .avatar-wrap { 102 | margin: 5px; 103 | float: left; 104 | width: 75px; 105 | } 106 | 107 | .user-login { 108 | margin-bottom: 0; 109 | } 110 | 111 | /* comments */ 112 | .issue-comments { 113 | cursor: pointer; 114 | float: right; 115 | 116 | font-family: "Trebuchet MS", Helvetica, sans-serif; 117 | background: linear-gradient(to right, #eee, #eef); 118 | color: #001; 119 | width: 10em; 120 | font-weight: bold; 121 | text-align: center; 122 | 123 | margin: 0em; 124 | padding: 0.5em; 125 | 126 | box-shadow: 1px 5px 5px #222; 127 | border-bottom-right-radius: 0.5em; 128 | 129 | height: 2.5em; 130 | 131 | position: absolute; 132 | bottom: 0; 133 | right: 0; 134 | } 135 | 136 | 137 | .issue-comments.true{ 138 | background: linear-gradient(to right, #345, #446); 139 | color: #eef; 140 | } 141 | 142 | .issue-comments.true:hover{ 143 | background: linear-gradient(to right, #eee, #eef); 144 | } 145 | 146 | .issue-comments:hover { 147 | background: linear-gradient(to right, #345, #446); 148 | color: #eef; 149 | } 150 | 151 | .issue-number { 152 | width: 6em; 153 | font-weight: bold; 154 | } 155 | 156 | 157 | .top-level-snooze { 158 | cursor: pointer; 159 | float: right; 160 | 161 | font-family: "Trebuchet MS", Helvetica, sans-serif; 162 | background: linear-gradient(to right, #eee, #eef); 163 | color: #001; 164 | width: 10em; 165 | font-weight: bold; 166 | text-align: center; 167 | 168 | margin: 0em; 169 | padding: 0.5em; 170 | 171 | box-shadow: 1px 5px 5px #222; 172 | border-bottom-right-radius: 0.5em; 173 | 174 | height: 2.5em; 175 | 176 | position: absolute; 177 | bottom: 0; 178 | right: 0; 179 | } 180 | .top-level-snooze:hover { 181 | background: linear-gradient(to right, #345, #446); 182 | color: #eef; 183 | } 184 | -------------------------------------------------------------------------------- /client/issues/issue.html: -------------------------------------------------------------------------------- 1 | 70 | 71 | 72 | 80 | -------------------------------------------------------------------------------- /client/issues/issue.js: -------------------------------------------------------------------------------- 1 | Template.issue.helpers({ 2 | "statusColor" : function () { 3 | var mytag = this.status || "active"; 4 | return States.findOne({ tag: mytag }).color; 5 | }, 6 | "status": function () { 7 | var mytag = this.status || "active"; 8 | return States.findOne({ tag: mytag }).name; 9 | }, 10 | // XXX: I wish there was an easier way to filter this w/o doing extra work. 11 | "nonProjectLabels": function () { 12 | var self = this; 13 | return _.filter(self.issueDocument.labels, function (l) { 14 | return ! startsWithProject(l); 15 | }); 16 | }, 17 | "projectLabels": function () { 18 | var self = this; 19 | return _.filter(self.issueDocument.labels, function (l) { 20 | return startsWithProject(l); 21 | }); 22 | }, 23 | "numRecentComments": function () { 24 | var self = this; 25 | return self.recentCommentsCount; 26 | }, 27 | displayRecentComments: function () { 28 | return this.recentCommentsCount && Session.get(displayId(this)); 29 | }, 30 | displayNeedsResponseButton: function () { 31 | return this.status !== 'unresponded' && Meteor.userId(); 32 | } 33 | }); 34 | 35 | var startsWithProject = function (label) { 36 | return label.name.match(/^Project:/); 37 | }; 38 | 39 | 40 | Template.issue.events({ 41 | 'click .issue-comments': function () { 42 | Session.set(displayId(this), ! Session.get(displayId(this))); 43 | }, 44 | 'click .needs-response': function () { 45 | Meteor.call('needsResponse', { 46 | repoOwner: this.repoOwner, 47 | repoName: this.repoName, 48 | number: this.issueDocument.number 49 | }); 50 | Session.set(displayId(this), false); 51 | }, 52 | 'click .top-level-snooze': function () { 53 | Meteor.call('snooze', { 54 | repoOwner: this.repoOwner, 55 | repoName: this.repoName, 56 | number: this.issueDocument.number 57 | }); 58 | Session.set(displayId(this), false); 59 | }, 60 | 'click .claim-button': function() { 61 | Meteor.call('claim', { 62 | repoOwner: this.repoOwner, 63 | repoName: this.repoName, 64 | number: this.issueDocument.number 65 | }); 66 | }, 67 | 'click .unclaim-button': function() { 68 | Meteor.call('unclaim', { 69 | repoOwner: this.repoOwner, 70 | repoName: this.repoName, 71 | number: this.issueDocument.number 72 | }); 73 | } 74 | }); 75 | -------------------------------------------------------------------------------- /client/issues/models.js: -------------------------------------------------------------------------------- 1 | displayId = function (issue) { 2 | return "displayRecentComments:" + issue._id; 3 | }; 4 | -------------------------------------------------------------------------------- /client/labels/label.css: -------------------------------------------------------------------------------- 1 | .label { 2 | float: left; 3 | padding: 3px; 4 | margin: 2px; 5 | border-radius: 2px; 6 | border-size: 2px; 7 | border-color: transparent; 8 | font-family: "Trebuchet MS", Helvetica, sans-serif; 9 | vertical-align: middle; 10 | } -------------------------------------------------------------------------------- /client/labels/label.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /client/labels/label.js: -------------------------------------------------------------------------------- 1 | // Figure out the right font color for this label. For darker backgrounds, we 2 | // want to use white, but otherwise use black. 3 | var highContrastFontColor = function (color) { 4 | if (! color.match(/00/g)) { 5 | return "002"; 6 | } 7 | return "fff"; 8 | }; 9 | 10 | Template.label.helpers({ 11 | fontColor: function () { 12 | return highContrastFontColor(this.color); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /client/lib/helpers.js: -------------------------------------------------------------------------------- 1 | Template.registerHelper('showTimestamp', function (then) { 2 | // From glasser:reactive-fromnow. 3 | return ReactiveFromNow(then, { 4 | // Show absolute dates for things more than 10 days ago. 5 | maxRelativeMs: 1000*60*60*24*10, 6 | // We don't care about time for old things, just date. 7 | absoluteFormat: 'YYYY-MMM-DD' 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /client/main-page/main.css: -------------------------------------------------------------------------------- 1 | .background-maker { 2 | background: 3 | linear-gradient(to right, 4 | rgba(0,0,200,0.1), 5 | rgba(0,0,200,0.3), 6 | rgba(0,0,200,0.1)); 7 | } 8 | 9 | /* state buttons! */ 10 | .state-nav { 11 | background: rgba(0,0,200,0.2); 12 | border: 2px solid black; 13 | padding: 1em; 14 | color: #ddf; 15 | font-weight: bolder; 16 | font-family: "Trebuchet MS", Helvetica, sans-serif; 17 | margin-right: 0px; 18 | padding-right: 0px; 19 | } 20 | 21 | .state-button { 22 | border: 2px solid black; 23 | padding: 1em; 24 | border-radius: 1em; 25 | float: left; 26 | cursor: pointer; 27 | opacity: 0.5; 28 | } 29 | 30 | 31 | .state-button.true { 32 | border: 2px solid blue; 33 | padding: 1em; 34 | border-radius: 1em; 35 | float: left; 36 | opacity: 1.0; 37 | } 38 | 39 | /* search for a tag */ 40 | .tag-search { 41 | float: right; 42 | } 43 | 44 | 45 | .search-button { 46 | cursor: pointer; 47 | border: 1px solid black; 48 | border-radius: 1em; 49 | padding: 5px; 50 | } 51 | 52 | input { 53 | color: #000; 54 | } 55 | -------------------------------------------------------------------------------- /client/main-page/main.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 40 | 41 | 43 | 44 | 51 | -------------------------------------------------------------------------------- /client/main-page/main.js: -------------------------------------------------------------------------------- 1 | Counts = new Mongo.Collection("counts"); 2 | States = new Mongo.Collection(null); 3 | 4 | Meteor.startup(function () { 5 | // new 6 | States.insert({ tag: "unresponded", name: "Unresponded", color: "D2B91B", urgency: 10 }); 7 | 8 | // active 9 | States.insert({ tag: "active", name: "Active", color: "F22", urgency: 7 }); 10 | 11 | // triaged 12 | States.insert({ tag: "triaged", name: "Triaged", color: "33aa55", urgency: 3 }); 13 | 14 | // resolved 15 | States.insert({ tag: "resolved", name: "Resolved", color: "777", urgency: 1 }); 16 | 17 | // stirring 18 | States.insert({ tag: "stirring", name: "Stirring", color: "FAAC58", urgency: 9 }); 19 | 20 | // highly-active 21 | States.insert({ tag: "highly-active", name: "Highly Active", color: "77F", urgency: 5 }); 22 | }); 23 | 24 | Template.issueNav.helpers({ 25 | states: function () { 26 | return States.find({}, { sort: { urgency: -1 }}); 27 | }, 28 | numIssues: function () { 29 | // Otherwise, return how many issues we have. 30 | return Counts.findOne(this.tag) && Counts.findOne(this.tag).count; 31 | }, 32 | filter: function () { 33 | var me = document.getElementById("tag-search"); 34 | return (me && me.value) || Session.get("labelFilterRaw"); 35 | } 36 | }); 37 | 38 | Template.viewIssues.helpers({ 39 | issues: function () { 40 | var selectedStates = _.pluck(States.find({ selected: true }).fetch(), 'tag'); 41 | var finder = constructIssueFinder(selectedStates, Session.get("labelFilter")); 42 | return Issues.find(finder, { sort: { lastUpdateOrComment: -1 } }); 43 | } 44 | }); 45 | 46 | var constructIssueFinder = function(states, tags) { 47 | var finder = constructTagFilter(tags); 48 | if (! _.isEmpty(states)) { 49 | _.extend(finder, { status: { $in: states }}); 50 | } 51 | return finder; 52 | }; 53 | 54 | Template.unlabeledIssues.onCreated(function () { 55 | this.subscribe('unlabeled-open'); 56 | }); 57 | 58 | Template.unlabeledIssues.helpers({ 59 | issues: function () { 60 | return Issues.find({ 61 | 'issueDocument.open': true, 62 | 'issueDocument.hasProjectLabel': false 63 | }, { sorted: { 'issueDocument.updatedAt': -1 } }); 64 | } 65 | }); 66 | 67 | Template.issueNav.events({ 68 | 'click .state-button' : function () { 69 | States.update(this._id, { $set: { selected: ! this.selected }}); 70 | Router.go(compileLink()); 71 | }, 72 | 'click .search-button' : function () { 73 | filterByTag(document.getElementById("tag-search").value); 74 | Tracker.afterFlush(function () { 75 | // let raw->cooked percolate 76 | Router.go(compileLink()); 77 | }); 78 | }, 79 | 'keyup #tag-search': function (evt, template) { 80 | // We were going to filter on enter (we need to check that evt.which === 81 | // 13), but then, this is kind of cool? 82 | filterByTag(document.getElementById("tag-search").value); 83 | Tracker.afterFlush(function () { 84 | // let raw->cooked percolate 85 | Router.go(compileLink()); 86 | }); 87 | } 88 | }); 89 | 90 | filterByTag = function (tag) { 91 | Session.set("labelFilterRaw", tag); 92 | }; 93 | 94 | Tracker.autorun(function () { 95 | var raw = Session.get('labelFilterRaw'); 96 | if (!(raw && raw.match(/\S/))) { 97 | Session.set('labelFilter', null); 98 | } else { 99 | Session.set('labelFilter', raw.trim().split(/\s+/)); 100 | } 101 | }); 102 | 103 | Template.subscribe.onCreated( function () { 104 | this.subscribe('issues-by-status', this.data.tag); 105 | }); 106 | 107 | Tracker.autorun(function () { 108 | var label = Session.get("labelFilter"); 109 | Meteor.subscribe("status-counts", label); 110 | }); 111 | -------------------------------------------------------------------------------- /client/navbar/navbar.css: -------------------------------------------------------------------------------- 1 | .navbar-custom { 2 | background-color:#000022; 3 | color:#fff; 4 | border-radius:0; 5 | padding: 0px; 6 | position: relative; 7 | } 8 | 9 | .navbar-custom .navbar-nav > li > a { 10 | color:#000; 11 | } 12 | .navbar-custom .navbar-nav > .active > a, .navbar-nav > .active > a:hover, .navbar-nav > .active > a:focus { 13 | color: #000; 14 | background-color:transparent; 15 | } 16 | .navbar-custom .navbar-brand { 17 | color:#eeeeee; 18 | position: absolute; 19 | float: left; 20 | } 21 | 22 | .navbar-link { 23 | position: absolute; 24 | top: 0.5em; 25 | left: 15em; 26 | border-radius: 5px; 27 | padding: 5px; 28 | border: 2px solid #0431B4; 29 | cursor: pointer; 30 | color: #2C53BD; 31 | } 32 | 33 | 34 | .navbar-link:hover { 35 | color: #FFF; 36 | background: #0431B4; 37 | } 38 | 39 | .logo { 40 | float: left; 41 | } 42 | 43 | /* #2C53BD */ -------------------------------------------------------------------------------- /client/navbar/navbar.html: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | 36 | -------------------------------------------------------------------------------- /client/navbar/navbar.js: -------------------------------------------------------------------------------- 1 | Template.logo.helpers({ 2 | circles: function () { 3 | return [ 4 | { x: 24, y: 15, r: 11 }, 5 | { x: 16, y: 24, r: 8 }, 6 | { x: 10, y: 30, r: 6 } 7 | ]; 8 | } 9 | }); 10 | 11 | Template.navbar.helpers({ 12 | labels: function () { 13 | return ! (Router.current().route.path() === "/unlabeled"); 14 | return true; 15 | } 16 | }); 17 | 18 | compileLink = function () { 19 | var url = ""; 20 | if (States.findOne({ selected: true })) { 21 | var selected = States.find({ selected: true }).fetch(); 22 | var statesStr = _.pluck(selected, "tag").join("+"); 23 | url += "/states/" + statesStr; 24 | } 25 | var filter = Session.get('labelFilter'); 26 | if (filter) { 27 | url += "/filter/" + filter.join("+"); 28 | } 29 | return url || "/"; 30 | }; 31 | 32 | Template.navbar.events({ 33 | 'click .navbar-link': function () { 34 | Router.go("/unlabeled"); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "async": { 4 | "version": "0.9.0" 5 | }, 6 | "github": { 7 | "version": "0.2.3" 8 | }, 9 | "github-webhook-handler": { 10 | "version": "https://github.com/meteor/github-webhook-handler/tarball/76879a0f2e5eaaa0ba3cbc54715de23a0b3f9984", 11 | "dependencies": { 12 | "bl": { 13 | "version": "0.8.2", 14 | "dependencies": { 15 | "readable-stream": { 16 | "version": "1.0.33", 17 | "dependencies": { 18 | "core-util-is": { 19 | "version": "1.0.1" 20 | }, 21 | "isarray": { 22 | "version": "0.0.1" 23 | }, 24 | "string_decoder": { 25 | "version": "0.10.31" 26 | }, 27 | "inherits": { 28 | "version": "2.0.1" 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/README.md: -------------------------------------------------------------------------------- 1 | # hubble:issue-sync 2 | 3 | Syncs GitHub issues into MongoDB database. 4 | 5 | ## Schema 6 | 7 | User always looks like an object with: 8 | - login: String 9 | - id: Number 10 | - avatarUrl: String 11 | - url: String 12 | - htmlUrl: String 13 | 14 | Issue: 15 | - repoOwner: String `meteor` 16 | - repoName: String `meteor` 17 | - issueDocument: (from GitHub) 18 | - id: Number 19 | - url: String 20 | - htmlUrl: String 21 | - number: Number 22 | - open: Boolean (from String (open/closed)) 23 | - title: String 24 | - body: String (Markdown) 25 | - user: User 26 | - labels: Array of Objects 27 | - url: String 28 | - name: String 29 | - color: String 30 | - hasProjectLabel: Boolean (from labels) 31 | - assignee: optional User 32 | - commentCount: Number 33 | - milestone: optional Object 34 | - url: String 35 | - number: Number 36 | - open: Boolean (from String open/closed) 37 | - title: String 38 | - description: String 39 | - pullRequest: optional Object 40 | - url: String 41 | - diffUrl: String 42 | - htmlUrl: String 43 | - patchUrl: String 44 | - closedBy: optional User 45 | - createdAt: Date (from String) 46 | - closedAt: Date (from String) 47 | - updatedAt: Date (from String) 48 | - snoozes: Array of Object 49 | - when: Date 50 | - login: String 51 | - id: Number (github user id) 52 | - needsResponses: Array of Object 53 | - when: Date 54 | - login: String 55 | - id: Number (github user id) 56 | - highlyActive: Boolean 57 | - highlyActiveLog: Array of Object 58 | - when: Date 59 | - login: String 60 | - id: Number (github user id) 61 | - setTo: Boolean 62 | - comments: Map 63 | - key: created_at STRING + '!' + id 64 | - value: Object 65 | - id: Number 66 | - url: String 67 | - htmlUrl: String 68 | - body: String 69 | - bodyHtml: String, 70 | - user: User 71 | - createdAt: Date (from String) 72 | - updatedAt: Date (from String) 73 | - manuallyMarkedAsResponded: Boolean 74 | Needs to be manually set on the database. This allows an issue 75 | to transition out of 'unresponded' despite not actually being responded 76 | to. In March 2015 it was added to all unresponded issues that had had 77 | no actions in 2015 (all of these issues were also closed). It's effectively 78 | equivalent to "opened by team member". 79 | - recentComments*: Map 80 | This is a subset of comments containing comments since the last 81 | MDG comment or snooze, for issues that have any response at all. 82 | - recentCommentsCount*: Number 83 | - msSpentInNew*: Number 84 | - canBeSnoozed*: Boolean 85 | - lastUpdateOrComment*: Date 86 | - status*: 87 | In the following, "Responded" means that the issue was opened by a team 88 | member, or there is comment by a team member, or the manuallyMarkedAsResponded 89 | flag is set. 90 | - mystery -- we've recorded comments for this but we haven't recorded 91 | issue metadata. (Probably never publish these!) 92 | - unresponded -- one of the following: 93 | - not Responded 94 | - there has been a "needs response" with no MDG comment or snooze since 95 | then 96 | - active -- *open* and Responded and not highlyActive and last 97 | opener/comment/snooze is non-MDG, and no overriding needsResponse 98 | - stirring -- *closed* and Responded and not highlyActive and last 99 | opener/comment/snooze is non-MDG, and no overriding needsResponse 100 | - triaged -- *open* and Responded and not highly Active and last 101 | opener/comment/snooze is MDG, and no overriding needsReponse 102 | - resolved -- *closed* and Responded and not highly Active and last 103 | opener/comment/snooze is MDG, and no overriding needsReponse 104 | - highly-active -- Responded and has highlyActive set 105 | 106 | `*` means "derived deterministically from other values on the document (plus 107 | list of MDG members)". 108 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/async.js: -------------------------------------------------------------------------------- 1 | // P is the only non-export package-local. Other things that would be 2 | // package-local will go on it. 3 | P = {}; 4 | 5 | var Future = Npm.require('fibers/future'); 6 | 7 | P.async = Npm.require('async'); 8 | 9 | // Need this to enable async.waterfall. 10 | var savedAsyncSetImmediate = P.async.setImmediate; 11 | P.async.setImmediate = function (fn) { 12 | savedAsyncSetImmediate(Meteor.bindEnvironment(fn)); 13 | }; 14 | 15 | P.asyncMethod = function (name, body) { 16 | var m = {}; 17 | m[name] = function () { 18 | var self = this; 19 | var f = new Future; 20 | var args = _.toArray(arguments); 21 | // minus 1 because the last arg is the callback 22 | if (args.length !== body.length - 1) { 23 | throw new Meteor.Error(400, "Expected " + (body.length - 1) + " args"); 24 | } 25 | args.push(f.resolver()); 26 | body.apply(self, args); 27 | return f.wait(); 28 | }; 29 | Meteor.methods(m); 30 | }; 31 | 32 | // Like async.series but returning null instead of an array of results; or like 33 | // "eachSeries but the iterator is just calling the function". 34 | P.asyncVoidSeries = function (arr, cb) { 35 | P.async.eachSeries(arr, function (task, cb) { 36 | task(cb); 37 | }, cb); 38 | }; 39 | 40 | P.requireLoggedIn = function (cb) { 41 | cb(Meteor.userId() ? null : new Meteor.Error("Must be logged in")); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/claim.js: -------------------------------------------------------------------------------- 1 | P.asyncMethod('claim', function (options, cb) { 2 | var mongoId, githubUsername; 3 | P.asyncVoidSeries([ 4 | P.requireLoggedIn, 5 | _.partial(P.asyncCheck, options, { 6 | repoOwner: String, 7 | repoName: String, 8 | number: Match.Integer 9 | }), 10 | function (cb) { 11 | mongoId = P.issueMongoId( 12 | options.repoOwner, options.repoName, options.number); 13 | if (! Issues.findOne(mongoId)) { 14 | cb(new Meteor.Error(404, "No such issue")); 15 | return; 16 | } 17 | var user = Meteor.user(); 18 | if (P.asyncErrorCheck(user, Match.ObjectIncluding({ 19 | services: Match.ObjectIncluding({ 20 | github: Match.ObjectIncluding({ 21 | username: String 22 | }) 23 | }) 24 | }), cb)) return; 25 | githubUsername = user.services.github.username; 26 | 27 | // Unclaim everything. 28 | Issues.update({claimedBy: githubUsername}, 29 | {$unset: {claimedBy: 1}}, 30 | {multi: true}, 31 | cb); 32 | }, 33 | function (cb) { 34 | // Claim this one. 35 | Issues.update(mongoId, {$set: {claimedBy: githubUsername}}, cb); 36 | } 37 | ], cb); 38 | }); 39 | P.asyncMethod('unclaim', function (options, cb) { 40 | P.asyncVoidSeries([ 41 | P.requireLoggedIn, 42 | _.partial(P.asyncCheck, options, { 43 | repoOwner: String, 44 | repoName: String, 45 | number: Match.Integer 46 | }), 47 | function (cb) { 48 | var mongoId = P.issueMongoId( 49 | options.repoOwner, options.repoName, options.number); 50 | if (! Issues.findOne(mongoId)) { 51 | cb(new Meteor.Error(404, "No such issue")); 52 | return; 53 | } 54 | Issues.update(mongoId, { 55 | $set: { 56 | claimedBy: null 57 | } 58 | }, cb); 59 | } 60 | ], cb); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/classify.js: -------------------------------------------------------------------------------- 1 | // Classify issues by status. 2 | 3 | // -------------------------------------- 4 | // ACTIONS THAT CAN AFFECT CLASSIFICATION 5 | // -------------------------------------- 6 | 7 | P.asyncMethod('snooze', function (options, cb) { 8 | recordAction('snoozes', options, cb); 9 | }); 10 | 11 | P.asyncMethod('needsResponse', function (options, cb) { 12 | recordAction('needsResponses', options, cb); 13 | }); 14 | 15 | 16 | var recordAction = function (actionField, options, cb) { 17 | var mongoId; 18 | 19 | P.asyncVoidSeries([ 20 | P.requireLoggedIn, 21 | _.partial(P.asyncCheck, options, Match.ObjectIncluding({ 22 | repoOwner: String, 23 | repoName: String, 24 | number: Match.Integer 25 | })), 26 | function (cb) { 27 | mongoId = P.issueMongoId( 28 | options.repoOwner, options.repoName, options.number); 29 | if (! Issues.findOne(mongoId)) { 30 | cb(new Meteor.Error(404, "No such issue")); 31 | return; 32 | } 33 | var user = Meteor.user(); 34 | if (P.asyncErrorCheck(user, Match.ObjectIncluding({ 35 | services: Match.ObjectIncluding({ 36 | github: Match.ObjectIncluding({ 37 | id: Match.Integer, 38 | username: String 39 | }) 40 | }) 41 | }), cb)) return; 42 | 43 | var update = { $push: {} }; 44 | update.$push[actionField] = { 45 | when: new Date, 46 | login: user.services.github.username, 47 | id: user.services.github.id 48 | }; 49 | Issues.update(mongoId, update, cb); 50 | }, 51 | function (cb) { 52 | P.needsClassification(mongoId, cb); 53 | } 54 | ], cb); 55 | }; 56 | 57 | P.asyncMethod('setHighlyActive', function (options, cb) { 58 | var mongoId; 59 | 60 | P.asyncVoidSeries([ 61 | P.requireLoggedIn, 62 | _.partial(P.asyncCheck, options, Match.ObjectIncluding({ 63 | repoOwner: String, 64 | repoName: String, 65 | number: Match.Integer, 66 | highlyActive: Boolean 67 | })), 68 | function (cb) { 69 | mongoId = P.issueMongoId( 70 | options.repoOwner, options.repoName, options.number); 71 | if (! Issues.findOne(mongoId)) { 72 | cb(new Meteor.Error(404, "No such issue")); 73 | return; 74 | } 75 | var user = Meteor.user(); 76 | if (P.asyncErrorCheck(user, Match.ObjectIncluding({ 77 | services: Match.ObjectIncluding({ 78 | github: Match.ObjectIncluding({ 79 | id: Match.Integer, 80 | username: String 81 | }) 82 | }) 83 | }))) return; 84 | 85 | Issues.update(mongoId, { 86 | $set: { 87 | highlyActive: options.highlyActive 88 | }, 89 | $push: { 90 | // This is just a log that we could use in the future to display who 91 | // set something highly active and to determine historical status 92 | // values. 93 | highlyActiveLog: { 94 | when: new Date, 95 | login: user.services.github.username, 96 | id: user.services.github.id, 97 | setTo: options.highlyActive 98 | } 99 | } 100 | }, cb); 101 | }, 102 | function (cb) { 103 | P.needsClassification(mongoId, cb); 104 | } 105 | ], cb); 106 | }); 107 | 108 | 109 | 110 | // ------------------------ 111 | // CLASSIFICATION ALGORITHM 112 | // ------------------------ 113 | 114 | var classifyIssueById = function (id, cb) { 115 | if (P.asyncErrorCheck(id, String, cb)) return; 116 | var doc = Issues.findOne(id); 117 | 118 | if (! doc) { 119 | cb(Error("Unknown issue: " + id)); 120 | return; 121 | } 122 | 123 | var mod = classificationModifier(doc); 124 | Issues.update(id, mod, cb); 125 | }; 126 | 127 | var classificationModifier = function (doc) { 128 | if (! doc.issueDocument) { 129 | // We don't actually have issue metadata (eg, we have comments but no 130 | // issue?) Just mark it as Mystery and move on. 131 | return { 132 | $set: { 133 | status: 'mystery', 134 | recentComments: {}, 135 | recentCommentsCount: 0, 136 | msSpentInNew: null, 137 | canBeSnoozed: false 138 | } 139 | }; 140 | } 141 | 142 | // When was the last snooze (or null if none)? 143 | var lastSnoozeDate = 144 | _.isEmpty(doc.snoozes) ? null : _.max(_.pluck(doc.snoozes, 'when')); 145 | // When was the last needsResponse (or null if none)? 146 | var lastNeedsResponseDate = 147 | _.isEmpty(doc.needsResponses) ? null 148 | : _.max(_.pluck(doc.needsResponses, 'when')); 149 | 150 | // "recent" means "since last team member commented or snoozed" 151 | var recentComments = {}; 152 | var comments = []; 153 | var teamComments = []; 154 | _.each(_.keys(doc.comments || {}).sort(), function (key) { 155 | var comment = doc.comments[key]; 156 | comments.push(comment); 157 | if (IsTeamMember(comment.user.id)) { 158 | recentComments = {}; 159 | teamComments.push(comment); 160 | } else if (! lastSnoozeDate || comment.createdAt > lastSnoozeDate) { 161 | recentComments[key] = comment; 162 | } 163 | }); 164 | 165 | // Was the issued opened by a team member? 166 | var teamOpener = IsTeamMember(doc.issueDocument.user.id); 167 | 168 | // Did a team member comment on it at all? 169 | var firstTeamComment = _.first(teamComments); 170 | var lastTeamComment = _.last(teamComments); 171 | var teamCommented = !! firstTeamComment; 172 | // Special case for pre-2015 unresponded issues. 173 | var manuallyMarkedAsResponded = !! doc.manuallyMarkedAsResponded; 174 | 175 | // Has the issue been explicitly marked as highly active? 176 | var highlyActive = !! doc.highlyActive; 177 | 178 | // What was the last comment (or null)? 179 | var lastComment = _.isEmpty(comments) ? null : _.last(comments); 180 | 181 | // Was the last comment by a team member? 182 | var lastCommentWasTeam = lastComment && IsTeamMember(lastComment.user.id); 183 | 184 | // Was the last publicly visible action by a team member? 185 | var lastPublicActionWasTeam = lastCommentWasTeam || 186 | (teamOpener && _.isEmpty(comments)); 187 | 188 | // Was the last action (including snooze) by a team member? (This only gets 189 | // you out of 'new' if it was a public action, but it can get you out of 190 | // active/stirring into triaged/resolved.) 191 | var lastActionWasTeam = ( 192 | lastPublicActionWasTeam || 193 | (lastSnoozeDate && 194 | (! lastComment || lastSnoozeDate > lastComment.createdAt))); 195 | 196 | // Has a team member indicated that this issue needs a response, and there has 197 | // not been a team member comment or snooze since then? 198 | var noResponseSinceNeedsReponse = ( 199 | lastNeedsResponseDate && 200 | (! (lastSnoozeDate && 201 | lastNeedsResponseDate < lastSnoozeDate)) && 202 | (! (lastTeamComment && 203 | lastNeedsResponseDate < lastTeamComment.createdAt))); 204 | 205 | // Is it currently open? 206 | var open = doc.issueDocument.open; 207 | 208 | // Did the opener close it and nobody else commented? 209 | var fastClose = ( 210 | ! open 211 | && doc.issueDocument.closedBy 212 | && doc.issueDocument.user.id === doc.issueDocument.closedBy.id 213 | && _.all(comments, function (comment) { 214 | return comment.user.id === doc.issueDocument.user.id; 215 | }) 216 | ); 217 | 218 | var status = null; 219 | if (noResponseSinceNeedsReponse) { 220 | // If we've explicitly put it in the "needs response" category, then it 221 | // stays there until we take it out of there. 222 | status = 'unresponded'; 223 | } else if (! teamOpener && ! teamCommented && ! fastClose && 224 | ! manuallyMarkedAsResponded) { 225 | // The only way to get out of unresponded is a publicly visible action by a 226 | // team member, or the special "fast close" case (or manually marking it 227 | // in the database, which we did once for legacy issues). 228 | status = 'unresponded'; 229 | } else if (highlyActive) { 230 | // Anything not unresponded with the highlyActive bit is HIGHLY-ACTIVE. 231 | status = 'highly-active'; 232 | } else if (open && lastActionWasTeam) { 233 | // It's open and we were the last to act (possibly by snoozing). 234 | status = 'triaged'; 235 | } else if (! open && (lastActionWasTeam || fastClose)) { 236 | // It's closed and either we were the last to act, or the opener is the only 237 | // user to interact with this issue at all and closed it. 238 | status = 'resolved'; 239 | } else if (open) { 240 | // It's open, and the last action was not a team member. 241 | status = 'active'; 242 | } else { 243 | // It's closed, and the last action was not a team member. 244 | status = 'stirring'; 245 | } 246 | 247 | // Calculate a statistic which tracks the first time that we responded to an 248 | // issue. 249 | var msSpentInNew = null; 250 | if (teamOpener) { 251 | msSpentInNew = 0; 252 | } else if (fastClose) { 253 | msSpentInNew = doc.issueDocument.closedAt - doc.issueDocument.createdAt; 254 | } else if (firstTeamComment) { 255 | msSpentInNew = firstTeamComment.createdAt - doc.issueDocument.createdAt; 256 | } 257 | 258 | var updates = _.pluck(doc.comments, 'updatedAt'); 259 | updates.push(doc.issueDocument.updatedAt); 260 | 261 | // Can we snooze this? We can't if it's in one of the two categories that 262 | // means we've already said everything we could say, and we also can't if 263 | // we've never made a public action. 264 | var canBeSnoozed = ( 265 | (teamOpener || teamCommented || fastClose) && 266 | status !== 'resolved' && status !== 'triaged'); 267 | 268 | return { 269 | $set: { 270 | status: status, 271 | recentComments: recentComments, 272 | recentCommentsCount: _.size(recentComments), 273 | msSpentInNew: msSpentInNew, 274 | lastUpdateOrComment: _.max(updates), 275 | canBeSnoozed: canBeSnoozed 276 | } 277 | }; 278 | }; 279 | 280 | 281 | // -------------------- 282 | // CLASSIFICATION QUEUE 283 | // -------------------- 284 | 285 | // Schema: 286 | // - _id: same id as Issues 287 | // - enqueued: Number (millis) enqueued (upsert with $max) 288 | var ClassificationQueue = P.newCollection('classificationQueue'); 289 | 290 | P.needsClassification = function (id, cb) { 291 | if (P.asyncErrorCheck(id, String, cb)) return; 292 | 293 | console.log("Needs classification:", id); 294 | 295 | ClassificationQueue.update( 296 | id, { $max: { enqueued: +(new Date) } }, { upsert: true }, cb); 297 | }; 298 | 299 | P.reclassifyAllIssues = function (cb) { 300 | console.log("Reclassifying all issues!"); 301 | var ids = Issues.find({}, { fields: { _id: 1 } }).fetch(); 302 | // XXX bulk insert!!! 303 | var when = +(new Date); 304 | P.async.each(ids, function (doc, cb) { 305 | ClassificationQueue.insert({_id: doc._id, enqueued: when}, cb); 306 | }, cb); 307 | }; 308 | 309 | P.asyncMethod('reclassifyAllIssues', function (cb) { 310 | var self = this; 311 | P.asyncVoidSeries([ 312 | P.requireLoggedIn, 313 | P.reclassifyAllIssues 314 | ], cb); 315 | }); 316 | 317 | // Classifies everything currently in the queue. Result is a bool saying whether 318 | // anything was seen. 319 | var classifyCurrentQueue = function (cb) { 320 | console.log("Starting to classify"); 321 | var queue = ClassificationQueue.find().fetch(); 322 | P.async.each(queue, function (queued, cb) { 323 | P.asyncVoidSeries([ 324 | function (cb) { 325 | classifyIssueById(queued._id, cb); 326 | }, 327 | function (cb) { 328 | // Only remove it if it hasn't already been updated with a newer 329 | // enqueued number! 330 | ClassificationQueue.remove(_.pick(queued, '_id', 'enqueued'), cb); 331 | } 332 | ], cb); 333 | }, function (err) { 334 | if (err) { 335 | cb(err); 336 | return; 337 | } 338 | cb(null, !! queue.length); 339 | }); 340 | }; 341 | 342 | var classifyForever = function () { 343 | classifyCurrentQueue(function (err, accomplished) { 344 | if (err) { 345 | console.error("Error classifying: " + err); 346 | // Try again in 10 seconds 347 | Meteor.setTimeout(classifyForever, 1000 * 10); 348 | return; 349 | } 350 | if (accomplished) { 351 | // If we managed to do something, try again immediately. 352 | Meteor.defer(classifyForever); 353 | return; 354 | } 355 | 356 | console.log("Waiting for classification queue"); 357 | 358 | var inInitialAdds = true; 359 | var stopInInitialAdds = false; 360 | var handle = ClassificationQueue.find().observeChanges({ 361 | added: function () { 362 | if (inInitialAdds) { 363 | // we don't have a handle yet during initial adds to stop it 364 | stopInInitialAdds = true; 365 | } else { 366 | handle.stop(); 367 | Meteor.defer(classifyForever); 368 | } 369 | } 370 | }); 371 | inInitialAdds = false; 372 | if (stopInInitialAdds) { 373 | handle.stop(); 374 | Meteor.defer(classifyForever); 375 | } 376 | }); 377 | }; 378 | 379 | Meteor.startup(classifyForever); 380 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/client.js: -------------------------------------------------------------------------------- 1 | // Write your package code here! 2 | if (Meteor.isClient) { 3 | Issues = new Mongo.Collection('issues'); 4 | } 5 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/config_server.js: -------------------------------------------------------------------------------- 1 | // ------------------- 2 | // MONGO CONFIGURATION 3 | // ------------------- 4 | 5 | (function () { 6 | var driver; 7 | if (Meteor.settings.mongo) { 8 | driver = new MongoInternals.RemoteCollectionDriver( 9 | Meteor.settings.mongo.mongoUrl, { 10 | oplogUrl: Meteor.settings.mongo.oplogUrl 11 | } 12 | ); 13 | } else { 14 | driver = MongoInternals.defaultRemoteCollectionDriver(); 15 | } 16 | 17 | P.newCollection = function (name) { 18 | return new Mongo.Collection(name, { _driver: driver }); 19 | }; 20 | })(); 21 | 22 | 23 | // ---------------- 24 | // GITHUB API SETUP 25 | // ---------------- 26 | 27 | var githubModule = Npm.require('github'); 28 | var githubError = Npm.require('github/error'); 29 | 30 | P.github = new githubModule({ 31 | version: '3.0.0', 32 | // Include body and body_html. (We don't trust our own Markdown generator to 33 | // be safe.) Including this on the individual request is tough because 34 | // getNextPage doesn't respect it. See 35 | // https://github.com/mikedeboer/node-github/issues/229 36 | requestMedia: 'application/vnd.github.VERSION.full+json', 37 | debug: !!process.env.GITHUB_API_DEBUG, 38 | headers: { 39 | "user-agent": "githubble.meteor.com" 40 | } 41 | }); 42 | 43 | (function () { 44 | // This is a "personal access token" with NO SCOPES from 45 | // https://github.com/settings/applications. When running locally, 46 | // create one through that interface (BUT UNCHECK ALL THE SCOPE BOXES) 47 | // and set it in $GITHUB_TOKEN. When running in production, we'll 48 | // share one that's in a settings file in lastpass. 49 | var token = Meteor.settings.githubToken || process.env.GITHUB_TOKEN; 50 | if (token) { 51 | P.github.authenticate({ 52 | type: 'token', 53 | token: token 54 | }); 55 | } 56 | })(); 57 | 58 | // For some reason, the errors from the github module don't show up well. 59 | var fixGithubError = function (e) { 60 | if (! (e instanceof githubError.HttpError)) 61 | return e; 62 | // note that e.message is a string with JSON, from github 63 | return new Error(e.message); 64 | }; 65 | 66 | // All callbacks passed to the GitHub API module should be passed through this, 67 | // which Fiberizes them and fixes some errors to work better. 68 | P.githubify = function (callback) { 69 | return Meteor.bindEnvironment(function (err, result) { 70 | if (err) { 71 | callback(fixGithubError(err)); 72 | } else { 73 | callback(null, result); 74 | } 75 | }); 76 | }; 77 | 78 | // -------------- 79 | // WEBHOOK CONFIG 80 | // -------------- 81 | 82 | var githubWebhookHandler = Npm.require('github-webhook-handler'); 83 | 84 | // The secret is a random string that you generate (eg, `openssl rand -hex 20`) 85 | // and set when you set up the webhook. Always set it in production (via 86 | // settings in lastpass), and generally set it while testing too --- otherwise 87 | // random people on the internet can insert stuff into your database! 88 | P.webhook = githubWebhookHandler({ 89 | secret: (Meteor.settings.githubWebhookSecret || 90 | process.env.GITHUB_WEBHOOK_SECRET) 91 | }); 92 | 93 | WebApp.connectHandlers.use('/webhook', Meteor.bindEnvironment(function (req, res, next) { 94 | if (req.method.toLowerCase() !== 'post') { 95 | next(); 96 | return; 97 | } 98 | 99 | P.webhook(req, res); 100 | })); 101 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/hubble:issue-sync-tests.js: -------------------------------------------------------------------------------- 1 | // Write your tests here! 2 | // Here is an example. 3 | Tinytest.add('example', function (test) { 4 | test.equal(true, true); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/match.js: -------------------------------------------------------------------------------- 1 | // Usage: 2 | // if (P.asyncCheck(v, p, cb)) return; 3 | // It does not call cb on success. 4 | P.asyncErrorCheck = function (value, pattern, cb) { 5 | try { 6 | check(value, pattern); 7 | } catch (e) { 8 | if (! (e instanceof Match.Error)) 9 | throw e; 10 | console.log("FAILED CHECK", value) 11 | cb(e); 12 | return true; 13 | } 14 | return false; 15 | }; 16 | 17 | // Always calls cb, either with error or with value. Good for a step in 18 | // async.waterfall. 19 | P.asyncCheck = function (value, pattern, cb) { 20 | try { 21 | check(value, pattern); 22 | } catch (e) { 23 | if (! (e instanceof Match.Error)) 24 | throw e; 25 | console.log("FAILED CHECK", value) 26 | cb(e); 27 | return; 28 | } 29 | cb(null, value); 30 | }; 31 | 32 | var maybeNull = function (pattern) { 33 | return Match.OneOf(null, pattern); 34 | }; 35 | 36 | var timestampMatcher = Match.Where(function (ts) { 37 | check(ts, String); 38 | return ts.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); 39 | }); 40 | 41 | P.Match = {}; 42 | 43 | // Matcher for User coming from GitHub's API (slightly different from our 44 | // internal schema!) 45 | P.Match.User = Match.ObjectIncluding({ 46 | login: String, 47 | id: Match.Integer, 48 | url: String, 49 | avatar_url: String, 50 | html_url: String 51 | }); 52 | 53 | // Matcher for Issue coming from GitHub's API (slightly different from our 54 | // internal schema!) 55 | P.Match.Issue = Match.ObjectIncluding({ 56 | id: Match.Integer, 57 | url: String, 58 | html_url: String, 59 | number: Match.Integer, 60 | state: Match.OneOf('open', 'closed'), 61 | title: String, 62 | body: String, 63 | user: P.Match.User, 64 | labels: [ 65 | Match.ObjectIncluding({ 66 | url: String, 67 | name: String, 68 | color: String 69 | }) 70 | ], 71 | assignee: maybeNull(P.Match.User), 72 | // closed_by does not appear at all in bulk issue lists, only in individual 73 | // issue gets. dunno why. Also it is null if the closing user is a deleted 74 | // user. 75 | closed_by: Match.Optional(maybeNull(P.Match.User)), 76 | comments: Match.Integer, 77 | milestone: maybeNull(Match.ObjectIncluding({ 78 | url: String, 79 | number: Number, 80 | state: Match.OneOf('open', 'closed'), 81 | title: String, 82 | description: maybeNull(String) 83 | })), 84 | pull_request: Match.Optional({ // not maybeNull! 85 | url: String, 86 | diff_url: String, 87 | html_url: String, 88 | patch_url: String 89 | }), 90 | created_at: timestampMatcher, 91 | closed_at: maybeNull(timestampMatcher), 92 | updated_at: timestampMatcher 93 | }); 94 | 95 | // Matcher for Comment coming from GitHub's API (slightly different from our 96 | // internal schema!) 97 | P.Match.Comment = Match.ObjectIncluding({ 98 | id: Match.Integer, 99 | url: String, 100 | html_url: String, 101 | issue_url: String, // we parse this but don't save it 102 | body: String, 103 | body_html: String, 104 | user: P.Match.User, 105 | created_at: timestampMatcher, 106 | updated_at: timestampMatcher 107 | }); 108 | 109 | // Matcher for Repository coming from GitHub's API (different from our internal 110 | // schema!) 111 | P.Match.Repository = Match.ObjectIncluding({ 112 | owner: Match.ObjectIncluding({ 113 | login: String 114 | }), 115 | name: String 116 | }); 117 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'hubble:issue-sync', 3 | version: '0.0.1', 4 | // Brief, one-line summary of the package. 5 | summary: '', 6 | // URL to the Git repository containing the source code for this package. 7 | git: '', 8 | // By default, Meteor will default to using README.md for documentation. 9 | // To avoid submitting documentation, set this field to null. 10 | documentation: 'README.md' 11 | }); 12 | 13 | Npm.depends({ 14 | github: '0.2.3', 15 | async: '0.9.0', 16 | 'github-webhook-handler': 'https://github.com/meteor/github-webhook-handler/tarball/76879a0f2e5eaaa0ba3cbc54715de23a0b3f9984' 17 | }); 18 | 19 | Package.onUse(function(api) { 20 | api.use(['check', 'mongo', 'webapp', 'underscore']); 21 | api.addFiles(['async.js', 'config_server.js', 'match.js', 'sync_server.js', 22 | 'team.js', 'classify.js', 'claim.js'], 'server'); 23 | api.addFiles('client.js', 'client'); 24 | api.export('Issues'); 25 | api.export(['IsTeamMember', 'IsActiveTeamMember'], 'server'); 26 | }); 27 | 28 | Package.onTest(function(api) { 29 | api.use('tinytest'); 30 | api.use('hubble:issue-sync'); 31 | api.addFiles('hubble:issue-sync-tests.js'); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/sync_server.js: -------------------------------------------------------------------------------- 1 | // ----------- 2 | // MONGO SETUP 3 | // ----------- 4 | 5 | Issues = P.newCollection('issues'); 6 | Issues._ensureIndex({ 7 | repoOwner: 1, 8 | repoName: 1 9 | }); 10 | // XXX more indices? 11 | 12 | P.issueMongoId = function (repoOwner, repoName, number) { 13 | check(repoOwner, String); 14 | check(repoName, String); 15 | check(number, Match.Integer); 16 | return repoOwner + '/' + repoName + '#' + number; 17 | }; 18 | 19 | 20 | // id eg 'meteor/meteor#comments' 21 | // only relevant field is lastDate (String) 22 | var SyncedTo = P.newCollection('syncedTo'); 23 | var syncedToMongoId = function (repoOwner, repoName, which) { 24 | check(repoOwner, String); 25 | check(repoName, String); 26 | check(which, String); 27 | return repoOwner + '/' + repoName + '#' + which; 28 | }; 29 | 30 | 31 | // ------------------------------------------- 32 | // CONVERTING FROM GITHUB SCHEMA TO OUR SCHEMA 33 | // ------------------------------------------- 34 | 35 | var userResponseToObject = function (userResponse) { 36 | check(userResponse, P.Match.User); 37 | return { 38 | login: userResponse.login, 39 | id: userResponse.id, 40 | avatarUrl: userResponse.avatar_url, 41 | url: userResponse.url, 42 | htmlUrl: userResponse.html_url 43 | }; 44 | }; 45 | 46 | var issueResponseToModifier = function (options) { 47 | check(options, { 48 | repoOwner: String, 49 | repoName: String, 50 | issueResponse: P.Match.Issue 51 | }); 52 | 53 | var i = options.issueResponse; 54 | 55 | var mod = { 56 | $set: { 57 | repoOwner: options.repoOwner, 58 | repoName: options.repoName, 59 | 'issueDocument.id': i.id, 60 | 'issueDocument.url': i.url, 61 | 'issueDocument.htmlUrl': i.html_url, 62 | 'issueDocument.number': i.number, 63 | 'issueDocument.open': (i.state === 'open'), 64 | 'issueDocument.title': i.title, 65 | 'issueDocument.body': i.body, 66 | 'issueDocument.user': userResponseToObject(i.user), 67 | 'issueDocument.labels': _.map(i.labels, function (l) { 68 | return _.pick(l, 'url', 'name', 'color'); 69 | }), 70 | 'issueDocument.hasProjectLabel': _.any(i.labels, function (l) { 71 | return /^Project:/.test(l.name); 72 | }), 73 | 'issueDocument.assignee': ( 74 | i.assignee ? userResponseToObject(i.assignee) : null), 75 | 'issueDocument.commentCount': i.comments, 76 | 'issueDocument.milestone': ( 77 | i.milestone ? { 78 | url: i.milestone.url, 79 | number: i.milestone.number, 80 | open: (i.milestone.state === 'open'), 81 | title: i.milestone.title, 82 | description: i.milestone.description 83 | } : null), 84 | 'issueDocument.pullRequest': ( 85 | i.pull_request ? { 86 | url: i.pull_request.url, 87 | diffUrl: i.pull_request.diff_url, 88 | htmlUrl: i.pull_request.html_url, 89 | patchUrl: i.pull_request.patch_url 90 | } : null), 91 | 'issueDocument.createdAt': new Date(i.created_at), 92 | 'issueDocument.closedAt': i.closed_at ? new Date(i.closed_at) : null, 93 | 'issueDocument.updatedAt': new Date(i.updated_at) 94 | } 95 | }; 96 | 97 | // Only set closedBy if we were actually given one (null or not). 98 | if (_.has(i, 'closed_by')) { 99 | mod.$set['issueDocument.closedBy'] = 100 | i.closed_by ? userResponseToObject(i.closed_by) : null; 101 | } 102 | 103 | return mod; 104 | }; 105 | 106 | // With this key, comments in the comments map will be sorted in chronological 107 | // order if you sort by key. 108 | var commentKey = function (commentResponse) { 109 | return commentResponse.created_at + '!' + commentResponse.id; 110 | }; 111 | 112 | var commentResponseToModifier = function (options) { 113 | check(options, { 114 | repoOwner: String, 115 | repoName: String, 116 | commentResponse: P.Match.Comment 117 | }); 118 | 119 | var c = options.commentResponse; 120 | 121 | var key = commentKey(c); 122 | var mod = { $set: {} }; 123 | mod.$set['comments.' + key] = { 124 | id: c.id, 125 | url: c.url, 126 | htmlUrl: c.html_url, 127 | body: c.body, 128 | bodyHtml: c.body_html, 129 | user: userResponseToObject(c.user), 130 | createdAt: new Date(c.created_at), 131 | updatedAt: new Date(c.updated_at) 132 | }; 133 | return mod; 134 | }; 135 | 136 | 137 | // ------------------------------------------- 138 | // FUNCTIONS THAT ACTUALLY MODIFY THE DATABASE 139 | // ------------------------------------------- 140 | 141 | // We can't ever suggest more than this, sadly. 142 | var MAX_PER_PAGE = 100; 143 | 144 | var saveIssue = function (options, cb) { 145 | if (P.asyncErrorCheck(options, { 146 | repoOwner: String, 147 | repoName: String, 148 | issueResponse: P.Match.Issue 149 | }, cb)) return; 150 | 151 | var id = P.issueMongoId(options.repoOwner, 152 | options.repoName, 153 | options.issueResponse.number); 154 | 155 | // When we get the issues from repoIssues, they don't contain closed_by. When 156 | // we get them from getRepoIssue (used by resyncOneIssue), they do. So if 157 | // we're syncing a closed issue and don't already have its closed_by, we go 158 | // use resyncOneIssue instead. 159 | // 160 | // (Note that sometimes closed_by exists and is null, if the closing user is a 161 | // deleted ("ghost") user. See eg #1976.) 162 | if (options.issueResponse.closed_at && 163 | ! _.has(options.issueResponse, 'closed_by')) { 164 | var existing = Issues.findOne(id); 165 | var closedAtTimestamp = +(new Date(options.issueResponse.closed_at)); 166 | if (! (existing 167 | && existing.issueDocument 168 | && _.has(existing.issueDocument, 'closedBy') 169 | && existing.issueDocument.closedAt 170 | && (+existing.issueDocument.closedAt) === closedAtTimestamp)) { 171 | console.log("Fetching closed_by for " + id); 172 | resyncOneIssue({ 173 | repoOwner: options.repoOwner, 174 | repoName: options.repoName, 175 | number: options.issueResponse.number 176 | }, cb); 177 | return; 178 | } 179 | } 180 | 181 | var mod = issueResponseToModifier( 182 | _.pick(options, 'repoOwner', 'repoName', 'issueResponse')); 183 | 184 | P.async.waterfall([ 185 | function (cb) { 186 | // Specifying _id explicitly means we avoid fake upsert. fullResult lets 187 | // us check nModified. 188 | Issues.update(id, mod, { upsert: true, fullResult: true }, cb); 189 | }, 190 | function (result, cb) { 191 | // If nothing changed, do nothing. 192 | if (! (result.nModified || result.upserted)) { 193 | cb(); 194 | } else { 195 | P.needsClassification(id, cb); 196 | } 197 | } 198 | ], cb); 199 | }; 200 | 201 | // Saves a page of issues. 202 | var saveOnePageOfIssues = function (options, cb) { 203 | if (P.asyncErrorCheck(options, { 204 | repoOwner: String, 205 | repoName: String, 206 | issueResponses: [P.Match.Issue] 207 | }, cb)) return; 208 | 209 | var issues = options.issueResponses; 210 | 211 | if (! issues.length) { 212 | throw Error("empty page?"); 213 | } 214 | 215 | console.log("Saving " + issues.length + " issues for " + 216 | options.repoOwner + "/" + options.repoName + ": " + 217 | JSON.stringify(_.pluck(issues, 'number'))); 218 | P.async.each(issues, function (issueResponse, cb) { 219 | saveIssue({ 220 | repoOwner: options.repoOwner, 221 | repoName: options.repoName, 222 | issueResponse: issueResponse 223 | }, cb); 224 | }, cb); 225 | }; 226 | 227 | var resyncOneIssue = function (options, cb) { 228 | if (P.asyncErrorCheck(options, { 229 | repoOwner: String, 230 | repoName: String, 231 | number: Match.Integer 232 | }, cb)) return; 233 | 234 | P.github.issues.getRepoIssue({ 235 | user: options.repoOwner, 236 | repo: options.repoName, 237 | number: options.number 238 | }, P.githubify(function (err, issue) { 239 | if (err) { 240 | cb(err); 241 | return; 242 | } 243 | saveIssue({ 244 | repoOwner: options.repoOwner, 245 | repoName: options.repoName, 246 | issueResponse: issue 247 | }, cb); 248 | })); 249 | }; 250 | 251 | var resyncOneComment = function (options, cb) { 252 | if (P.asyncErrorCheck(options, { 253 | repoOwner: String, 254 | repoName: String, 255 | id: Match.Integer 256 | }, cb)) return; 257 | 258 | P.github.issues.getComment({ 259 | user: options.repoOwner, 260 | repo: options.repoName, 261 | id: options.id 262 | }, P.githubify(function (err, comment) { 263 | if (err) { 264 | cb(err); 265 | return; 266 | } 267 | saveComment({ 268 | repoOwner: options.repoOwner, 269 | repoName: options.repoName, 270 | commentResponse: comment 271 | }, cb); 272 | })); 273 | }; 274 | 275 | // Every so often, we resync all issues for a repo. This is good for a few 276 | // things: 277 | // - Filling in a newly added repo 278 | // - Adding things we made have missed if webhook events happened while we 279 | // were not deployed 280 | // Ideally, we would just save a "last updated" timestamp and use the "get all 281 | // repo issues sorted by updated_at since X" API. Unfortunately, label changes 282 | // don't appear to change the updated_at timestamp, so they won't get detected 283 | // by this. Ah well. 284 | var resyncAllIssues = function (options, cb) { 285 | if (P.asyncErrorCheck(options, { 286 | repoOwner: String, 287 | repoName: String 288 | }, cb)) return; 289 | 290 | console.log("Resyncing issues for " + 291 | options.repoOwner + "/" + options.repoName); 292 | 293 | var receivePageOfIssues = P.githubify(function (err, issues) { 294 | if (err) { 295 | cb(err); 296 | return; 297 | } 298 | if (! issues.length) { 299 | cb(); 300 | return; 301 | } 302 | 303 | saveOnePageOfIssues({ 304 | repoOwner: options.repoOwner, 305 | repoName: options.repoName, 306 | issueResponses: issues 307 | }, function (err) { 308 | if (err) { 309 | cb(err); 310 | } else if (P.github.hasNextPage(issues)) { 311 | P.github.getNextPage(issues, receivePageOfIssues); 312 | } else { 313 | cb(); 314 | } 315 | }); 316 | }); 317 | 318 | P.github.issues.repoIssues({ 319 | user: options.repoOwner, 320 | repo: options.repoName, 321 | per_page: MAX_PER_PAGE, 322 | state: 'all', 323 | sort: 'updated' // get newest in first, just because that's useful 324 | }, receivePageOfIssues); 325 | }; 326 | 327 | var saveComment = function (options, cb) { 328 | if (P.asyncErrorCheck(options, { 329 | repoOwner: String, 330 | repoName: String, 331 | commentResponse: P.Match.Comment 332 | }, cb)) return; 333 | 334 | var comment = options.commentResponse; 335 | 336 | // Ugh. Comments don't actually have an issue number! 337 | var issueUrlMatch = comment.issue_url.match(/\/issues\/(\d+)$/); 338 | if (! issueUrlMatch) { 339 | cb(Error("Bad issue URL: " + comment.issue_url)); 340 | return; 341 | } 342 | var issueNumber = +issueUrlMatch[1]; 343 | 344 | var issueId = P.issueMongoId( 345 | options.repoOwner, options.repoName, issueNumber); 346 | 347 | var mod = commentResponseToModifier( 348 | _.pick(options, 'repoOwner', 'repoName', 'commentResponse')); 349 | 350 | P.async.waterfall([ 351 | function (cb) { 352 | Issues.update({ 353 | // Specifying _id explicitly means we avoid fake upsert. 354 | _id: issueId, 355 | repoOwner: options.repoOwner, // so upserts sets it (good for index) 356 | repoName: options.repoName // ditto 357 | }, mod, { upsert: true, fullResult: true }, cb); 358 | }, 359 | function (result, cb) { 360 | // If nothing changed, do nothing. 361 | if (! (result.nModified || result.upsert)) { 362 | cb(); 363 | } else { 364 | P.needsClassification(issueId, cb); 365 | } 366 | } 367 | ], cb); 368 | }; 369 | 370 | // Saves a page of comments. 371 | var saveOnePageOfComments = function (options, cb) { 372 | if (P.asyncErrorCheck(options, { 373 | repoOwner: String, 374 | repoName: String, 375 | commentResponses: [P.Match.Comment] 376 | }, cb)) return; 377 | 378 | var comments = options.commentResponses; 379 | 380 | if (! comments.length) { 381 | throw Error("empty page?"); 382 | } 383 | 384 | P.async.each(comments, function (commentResponse, cb) { 385 | saveComment({ 386 | repoOwner: options.repoOwner, 387 | repoName: options.repoName, 388 | commentResponse: commentResponse 389 | }, cb); 390 | }, cb); 391 | }; 392 | 393 | // Syncs all comments. Unlike with issues, we can use a 'since' and not go back 394 | // to the beginning of time, because all changes we care about update the 395 | // updated_at date. 396 | var syncAllComments = function (options, cb) { 397 | if (P.asyncErrorCheck(options, { 398 | repoOwner: String, 399 | repoName: String 400 | }, cb)) return; 401 | 402 | var syncedToId = syncedToMongoId( 403 | options.repoOwner, options.repoName, 'comments'); 404 | var syncedToDoc = SyncedTo.findOne(syncedToId); 405 | var query = { 406 | user: options.repoOwner, 407 | repo: options.repoName, 408 | sort: 'updated', 409 | direction: 'asc', 410 | per_page: MAX_PER_PAGE 411 | }; 412 | if (syncedToDoc) { 413 | query.since = syncedToDoc.lastDate; 414 | } 415 | 416 | console.log('Syncing ' + syncedToId + 417 | (syncedToDoc ? ' since ' + syncedToDoc.lastDate : '')); 418 | 419 | var receivePageOfComments = P.githubify(function (err, comments) { 420 | if (err) { 421 | cb(err); 422 | return; 423 | } 424 | if (! comments.length) { 425 | cb(); 426 | return; 427 | } 428 | 429 | var newLastDate = _.last(comments).updated_at; 430 | console.log( 431 | "Saving " + comments.length + " comments for " + 432 | options.repoOwner + "/" + options.repoName + " up to " + newLastDate); 433 | 434 | saveOnePageOfComments({ 435 | repoOwner: options.repoOwner, 436 | repoName: options.repoName, 437 | commentResponses: comments 438 | }, function (err) { 439 | if (err) { 440 | cb(err); 441 | return; 442 | } 443 | // Save the last one we've successfully saved. (Note that the next call to 444 | // syncAllComments will resync this comment; seems better than trying to 445 | // add 1 and maybe missing a second comment from the same second.) 446 | SyncedTo.update( 447 | syncedToId, 448 | { $set: { lastDate: newLastDate } }, 449 | { upsert: true }, 450 | function (err) { 451 | if (err) { 452 | cb(err); 453 | return; 454 | } 455 | 456 | if (P.github.hasNextPage(comments)) { 457 | P.github.getNextPage(comments, receivePageOfComments); 458 | } else { 459 | cb(); 460 | } 461 | } 462 | ); 463 | }); 464 | }); 465 | 466 | P.github.issues.repoComments(query, receivePageOfComments); 467 | }; 468 | 469 | 470 | // -------- 471 | // WEBHOOKS 472 | // -------- 473 | 474 | var webhookComplain = function (err) { 475 | if (err) { 476 | console.error("Error in webhook:", err); 477 | } 478 | }; 479 | 480 | P.webhook.on('error', webhookComplain); 481 | 482 | P.webhook.on('issues', Meteor.bindEnvironment(function (event) { 483 | if (P.asyncErrorCheck(event.payload, Match.ObjectIncluding({ 484 | issue: P.Match.Issue, 485 | repository: P.Match.Repository 486 | }), webhookComplain)) return; 487 | 488 | saveIssue({ 489 | repoOwner: event.payload.repository.owner.login, 490 | repoName: event.payload.repository.name, 491 | issueResponse: event.payload.issue 492 | }, webhookComplain); 493 | })); 494 | 495 | P.webhook.on('pull_request', Meteor.bindEnvironment(function (event) { 496 | if (P.asyncErrorCheck(event.payload, Match.ObjectIncluding({ 497 | pull_request: Match.ObjectIncluding({ 498 | number: Match.Integer 499 | }), 500 | repository: P.Match.Repository 501 | }), webhookComplain)) return; 502 | 503 | // Unfortunately, the pull_request event inexplicably does not 504 | // contain labels, so we can't trust what we hear over the wire. 505 | // Do a full resync instead. 506 | resyncOneIssue({ 507 | repoOwner: event.payload.repository.owner.login, 508 | repoName: event.payload.repository.name, 509 | number: event.payload.pull_request.number 510 | }, webhookComplain); 511 | })); 512 | 513 | P.webhook.on('issue_comment', Meteor.bindEnvironment(function (event) { 514 | if (P.asyncErrorCheck(event.payload, Match.ObjectIncluding({ 515 | comment: Match.ObjectIncluding({ 516 | id: Match.Integer 517 | }), 518 | repository: P.Match.Repository 519 | }), webhookComplain)) return; 520 | 521 | // Unfortunately, the comment event only contains markdown and not HTML. We 522 | // have to go back and ask nicely for HTML. 523 | resyncOneComment({ 524 | repoOwner: event.payload.repository.owner.login, 525 | repoName: event.payload.repository.name, 526 | id: event.payload.comment.id 527 | }, webhookComplain); 528 | })); 529 | 530 | 531 | // -------- 532 | // CRONJOBS 533 | // -------- 534 | 535 | // When running locally, sync hubble by default. Note that this is just about 536 | // the cronjob; anything you set up with webhooks will be accepted. 537 | var REPO_TO_SYNC = Meteor.settings.sync 538 | ? _.pick(Meteor.settings.sync, 'repoOwner', 'repoName') 539 | : { repoOwner: 'meteor', repoName: 'hubble' }; 540 | 541 | 542 | // XXX rewrite to allow multiple repos 543 | var issueCronjob = function () { 544 | resyncAllIssues(REPO_TO_SYNC, function (err) { 545 | if (err) { 546 | console.error("Error in issue cronjob: " + err.stack); 547 | } 548 | console.log("Done issue cronjob"); 549 | // Full resync every 20 minutes, and on startup. (Webhook does the trick 550 | // otherwise.) 551 | Meteor.setTimeout(issueCronjob, 1000 * 60 * 20); 552 | }); 553 | }; 554 | Meteor.startup(issueCronjob); 555 | 556 | // XXX rewrite to allow multiple repos 557 | var commentCronjob = function () { 558 | syncAllComments(REPO_TO_SYNC, function (err) { 559 | if (err) { 560 | console.error("Error in comment cronjob: " + err.stack); 561 | } 562 | console.log("Done comment cronjob"); 563 | // Sync every minute, and on startup. (Webhook does the trick otherwise.) 564 | // 565 | // Because this one actually works incrementally (unlike the issue cronjob) 566 | // it's OK to make it once a minute. 567 | Meteor.setTimeout(commentCronjob, 1000 * 60); 568 | }); 569 | }; 570 | Meteor.startup(commentCronjob); 571 | -------------------------------------------------------------------------------- /packages/hubble:issue-sync/team.js: -------------------------------------------------------------------------------- 1 | // Manage a list of team members. 2 | 3 | 4 | // Documents are of the form: 5 | // - _id: Stringified numerical id 6 | // - login: String 7 | // - active: bool (active means "can log in and use this site", 8 | // inactive just means "their comments count as team member comments"; 9 | // in the future this could be extended to date ranges of comments counting) 10 | var TeamMembers = P.newCollection('teamMembers'); 11 | 12 | P.asyncMethod('addTeamMember', function (login, active, cb) { 13 | var self = this; 14 | P.asyncVoidSeries([ 15 | function (cb) { 16 | // You need to be logged in (which requires you to be one of these users) 17 | // unless you are trying to add the first user; 18 | if (! self.userId && TeamMembers.findOne()) { 19 | cb(new Meteor.Error("Not allowed")); 20 | } else { 21 | cb(); 22 | } 23 | }, 24 | _.partial(P.asyncCheck, login, String), 25 | _.partial(P.asyncCheck, active, Boolean), 26 | _.partial(addTeamMember, login, active) 27 | ], cb); 28 | }); 29 | 30 | P.asyncMethod('removeTeamMember', function (login, cb) { 31 | var self = this; 32 | P.asyncVoidSeries([ 33 | P.requireLoggedIn, 34 | _.partial(P.asyncCheck, login, String), 35 | function (cb) { 36 | TeamMembers.remove({ login: login }, cb); 37 | }, 38 | P.reclassifyAllIssues 39 | ], cb); 40 | }); 41 | 42 | var addTeamMember = function (login, active, cb) { 43 | P.async.waterfall([ 44 | function (cb) { 45 | P.github.user.getFrom({ user: login }, P.githubify(cb)); 46 | }, 47 | function (user, cb) { 48 | P.asyncCheck(user, P.Match.User, cb); 49 | }, 50 | function (user, cb) { 51 | // If the user already is in the database with an old username, replace 52 | // it. 53 | TeamMembers.update( 54 | { _id: ""+user.id }, 55 | { // this is a replace, not a modify! 56 | login: login, 57 | active: active, 58 | avatarUrl: user.avatar_url, 59 | htmlUrl: user.html_url 60 | }, 61 | { upsert: true }, 62 | cb); 63 | }, 64 | function (result, cb) { 65 | P.reclassifyAllIssues(cb); 66 | } 67 | ], cb); 68 | }; 69 | 70 | // map id -> active 71 | var MEMBERS_BY_ID = {}; 72 | 73 | TeamMembers.find().observe({ 74 | added: function (doc) { 75 | MEMBERS_BY_ID[doc._id] = doc.active; 76 | }, 77 | changed: function (doc) { 78 | MEMBERS_BY_ID[doc._id] = doc.active; 79 | }, 80 | removed: function (oldDoc) { 81 | delete MEMBERS_BY_ID[oldDoc._id]; 82 | } 83 | }); 84 | 85 | IsTeamMember = function (id) { 86 | return _.has(MEMBERS_BY_ID, ""+id); 87 | }; 88 | 89 | IsActiveTeamMember = function (id) { 90 | return _.has(MEMBERS_BY_ID, ""+id) && MEMBERS_BY_ID[""+id]; 91 | }; 92 | -------------------------------------------------------------------------------- /public/theme.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdg-private/hubble/1fdafdc0ba482778087677c165360175dc9041b1/public/theme.jpg -------------------------------------------------------------------------------- /publish.js: -------------------------------------------------------------------------------- 1 | if (Meteor.isServer) { 2 | var issueBoxFields = { 3 | repoOwner: 1, 4 | repoName: 1, 5 | "issueDocument.id": 1, 6 | "issueDocument.htmlUrl": 1, 7 | "issueDocument.number" : 1, 8 | "issueDocument.open" : 1, 9 | "issueDocument.title" : 1, 10 | "issueDocument.body" : 1, 11 | "issueDocument.labels" : 1, 12 | "issueDocument.user" : 1, 13 | "issueDocument.hasProjectLabel" : 1, 14 | "recentCommentsCount" : 1, 15 | lastUpdateOrComment: 1, 16 | highlyActive: 1, 17 | status: 1, 18 | canBeSnoozed: 1, 19 | claimedBy: 1 20 | }; 21 | 22 | Meteor.publish('issues-by-status', function (status) { 23 | check(status, String); 24 | return Issues.find({ 25 | status: status 26 | }, { fields: issueBoxFields }); 27 | }); 28 | 29 | Meteor.publish('unlabeled-open', function () { 30 | return Issues.find({ 31 | 'issueDocument.open': true, 32 | 'issueDocument.hasProjectLabel': false 33 | }, { fields: issueBoxFields }); 34 | }); 35 | 36 | Meteor.publish('status-counts', function (tags) { 37 | check(tags, Match.OneOf([String], null)); 38 | var self = this; 39 | var countsByStatus = {}; 40 | 41 | // Increment a given status, or set it to 1 if it doesn't exist. 42 | var incrementStatus = function (status) { 43 | if (!_.has(countsByStatus, status)) { 44 | countsByStatus[status] = 1; 45 | if (initializing) return; 46 | self.added("counts", status, { count: 1 }); 47 | } else { 48 | countsByStatus[status]++; 49 | if (initializing) return; 50 | self.changed("counts", status, { count: countsByStatus[status] }); 51 | } 52 | }; 53 | 54 | // Decrement a given status. 55 | var decrementStatus = function (status) { 56 | countsByStatus[status]--; 57 | self.changed("counts", status, { count: countsByStatus[status] }); 58 | }; 59 | 60 | var initializing = true; 61 | 62 | var finder = constructTagFilter(tags); 63 | var handle = Issues.find(finder, { fields: { status: 1 } }).observe({ 64 | added: function (doc) { 65 | if (! doc.status) return; 66 | incrementStatus(doc.status); 67 | }, 68 | changed: function (newDoc, oldDoc) { 69 | if (newDoc.status === oldDoc.status) return; 70 | oldDoc.status && decrementStatus(oldDoc.status); 71 | newDoc.status && incrementStatus(newDoc.status); 72 | }, 73 | removed: function (oldDoc) { 74 | oldDoc.status && decrementStatus(oldDoc.status); 75 | } 76 | }); 77 | 78 | initializing = false; 79 | 80 | _.each(countsByStatus, function (value, key) { 81 | self.added("counts", key, { count: value }); 82 | }); 83 | 84 | self.ready(); 85 | self.onStop(function () { 86 | handle.stop(); 87 | }); 88 | }); 89 | 90 | Meteor.publish('issue-recent-comments', function (id) { 91 | check(id, String); 92 | return Issues.find({ _id: id }, { fields: { recentComments: 1 } }); 93 | }); 94 | } 95 | 96 | var quotemeta = function (str) { 97 | return String(str).replace(/(\W)/g, '\\$1'); 98 | }; 99 | 100 | 101 | constructTagFilter = function(tags) { 102 | var goodReg = []; 103 | var badReg = []; 104 | _.each(tags, function (tag) { 105 | // If you're midway through typing a negative tag, don't filter out 106 | // everything. 107 | if (tag === '-') 108 | return; 109 | // We want to match *any* of the good tags, and we want none of the bad 110 | // tags. Fortunately that's exactly how $in and $nin work. 111 | if (tag.match(/^-/)) { 112 | badReg.push(new RegExp(quotemeta(tag.slice(1)), 'i')); 113 | } else { 114 | goodReg.push(new RegExp(quotemeta(tag), 'i')); 115 | } 116 | }); 117 | 118 | if (_.isEmpty(goodReg) && _.isEmpty(badReg)) { 119 | return {}; 120 | } 121 | 122 | var query = {'issueDocument.labels.name': {}}; 123 | if (goodReg.length) { 124 | query['issueDocument.labels.name'].$in = goodReg; 125 | } 126 | if (badReg.length) { 127 | query['issueDocument.labels.name'].$nin = badReg; 128 | } 129 | return query; 130 | }; 131 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | Router.configure({ 2 | layoutTemplate: "hello" 3 | }); 4 | 5 | Router.route('/', function () { 6 | this.layout("hello"); 7 | this.render("generic"); 8 | }); 9 | 10 | var setTag = function (tag) { 11 | Session.set("labelFilterRaw", tag); 12 | }; 13 | 14 | var setStates = function (states) { 15 | var selected = states.split(/\s+/); 16 | _.each(selected, function (state) { 17 | States.update({ tag: state }, {$set: { selected: true }}); 18 | }); 19 | // XXX glasser doesn't understand why this doesn't have to deselect the other 20 | // states too 21 | }; 22 | 23 | 24 | Router.route('/states/:_states', function () { 25 | this.layout("hello"); 26 | setStates(this.params._states); 27 | // XXX glasser doesn't understand why this doesn't have to unset filter too 28 | this.render("generic"); 29 | }, 'client'); 30 | 31 | 32 | Router.route('/filter/:_tag', function () { 33 | this.layout("hello"); 34 | setTag(this.params._tag); 35 | // XXX glasser doesn't understand why this doesn't have to unset states too 36 | this.render("generic"); 37 | }, 'client'); 38 | 39 | 40 | Router.route('/states/:_states/filter/:_tag', function () { 41 | this.layout("hello"); 42 | setStates(this.params._states); 43 | setTag(this.params._tag); 44 | this.render("generic"); 45 | }, 'client'); 46 | 47 | Router.route('/unlabeled', function () { 48 | this.layout('hello'); 49 | this.render('unlabeled'); 50 | }, 'client'); 51 | -------------------------------------------------------------------------------- /server/auth.js: -------------------------------------------------------------------------------- 1 | // An extra layer of protection, especially since we trust GitHub's HTML. 2 | BrowserPolicy.content.disallowInlineScripts(); 3 | 4 | // ... but we should allow GitHub avatar images. 5 | BrowserPolicy.content.allowImageOrigin( 6 | 'https://avatars.githubusercontent.com/'); 7 | // ... and GitHub emoji, etc. 8 | BrowserPolicy.content.allowImageOrigin( 9 | 'https://assets-cdn.github.com/'); 10 | // ... and images in posts. 11 | BrowserPolicy.content.allowImageOrigin( 12 | 'https://cloud.githubusercontent.com/'); 13 | 14 | Accounts.validateLoginAttempt(function (info) { 15 | check(info.user, Match.ObjectIncluding({ 16 | services: Match.ObjectIncluding({ 17 | github: Match.ObjectIncluding({ 18 | id: Match.Integer 19 | }) 20 | }) 21 | })); 22 | 23 | if (! IsActiveTeamMember(info.user.services.github.id)) { 24 | throw new Meteor.Error(400, "Only active team members may log in"); 25 | } 26 | 27 | return true; 28 | }); 29 | --------------------------------------------------------------------------------