├── .gitignore
├── LICENSE
├── README.md
├── gulpfile.js
├── package.json
└── src
├── background.js
├── contentScript.js
├── editor.js
├── logo.png
├── manifest.json
├── options.css
├── options.html
└── options.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules/
3 | bin/
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Monica Dinculescu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚨🚨🚨 This repo is unmaintained! 🚨🚨🚨
2 | Hai! Thanks for stopping by! Since GitHub shipped a similar feature for realsies, [Saved replies](https://help.github.com/articles/working-with-saved-replies/), there's no real point for me to maintain this extension. I've had requests from a couple of people not to nuke this repo since they'd still like to use this, so feel free to continue using it. Or not. It's really up to you. Here's a cactus :cactus:
3 |
4 | # github-canned-responses
5 |
6 | Sometimes you've made mistakes and you have to triage 700 GitHub issues and Pull Requests. A lot of them will need the same answer, and your wrists will start hurting from all the switch-to-edit-copy-pasting. This extension helps with that.
7 |
8 | ## How to get it
9 | [Install it from the Chrome Web Store](https://chrome.google.com/webstore/detail/github-canned-responses/lhehmppafakahahobaibfcomknkhoina)
10 |
11 | [Install it from the Firefox Add-on Store](https://addons.mozilla.org/en-US/firefox/addon/github-canned-responses/)
12 |
13 | ## What is it
14 | The extension adds a little button inside GitHub's
15 | comment editing view, that allows you to choose one of the existing canned responses, and insert it in the current comment. It looks like this, and it works on both Issues and PRs pages (but at the moment not the `files` view of a PR):
16 |
17 |
18 |
19 | ## Adding your own canned responses
20 | The extension comes with a few default canned responses, but you can edit them and add your own! In the dropdown, press the edit link, and you'll get something like this:
21 |
22 |
23 |
24 | ## Happy triaging, friends! <3
25 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var gulp = require('gulp');
4 | var jeditor = require('gulp-json-editor');
5 | var merge = require('merge-stream');
6 | var zip = require('gulp-zip');
7 |
8 | var xpiName = 'github-canned-responses.xpi';
9 | var zipName = 'github-canned-responses.zip';
10 |
11 | gulp.task('default', function () {
12 | var files = ['src/*', '!src/manifest.json'];
13 | var manifest = gulp.src('src/manifest.json');
14 | var dest = gulp.dest('bin/');
15 |
16 | merge(gulp.src(files), manifest)
17 | .pipe(zip(zipName))
18 | .pipe(dest);
19 |
20 | manifest = manifest.pipe(jeditor({
21 | 'applications': {
22 | 'gecko': {
23 | 'id': 'github-canned-responses@example'
24 | }
25 | }
26 | }));
27 |
28 | merge(gulp.src(files), manifest)
29 | .pipe(zip(xpiName))
30 | .pipe(dest);
31 | });
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "github-canned-responses",
3 | "version": "1.0.8",
4 | "description": "Choose from a set of canned responses when commenting on your GitHub PRs or issues",
5 | "main": "background.js",
6 | "dependencies": {
7 | "gulp": "^3.9.0",
8 | "gulp-json-editor": "^2.2.1",
9 | "gulp-zip": "^3.1.0",
10 | "merge-stream": "^1.0.0"
11 | },
12 | "devDependencies": {},
13 | "scripts": {
14 | "build": "gulp"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/notwaldorf/github-canned-responses.git"
19 | },
20 | "author": "Monica Dinculescu",
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/notwaldorf/github-canned-responses/issues"
24 | },
25 | "homepage": "https://github.com/notwaldorf/github-canned-responses#readme"
26 | }
27 |
--------------------------------------------------------------------------------
/src/background.js:
--------------------------------------------------------------------------------
1 | var defaultAnswers = [
2 | {
3 | name: 'Issue: thanks! help fix?',
4 | description: "Thanks a lot for filing this issue! Would you like to write a patch for this? We'd be more than happy to walk you through the steps involved."
5 | },
6 | {
7 | name: 'Issue: thanks! looking!',
8 | description: "Thanks a lot for filing this issue! We'll triage and take a look at it as soon as possible!"
9 | },
10 | {
11 | name: 'Issue: looks inactive',
12 | description: "This issue is fairly old and there hasn't been much activity on it. Closing, but please re-open if it still occurs."
13 | },
14 | {
15 | name: 'Issue: closing, no repro steps',
16 | description: "This issue has no reproducible steps. Please re-open this issue if it still occurs, with a JSBin containing a set of reproducible steps. Check this element's CONTRIBUTING.md for an example."
17 | },
18 | {
19 | name: 'Issue: provide repro steps',
20 | description: "Please provide a JSBin containing a set of reproducible steps. Check this element's CONTRIBUTING.md for an example."
21 | },
22 | {
23 | name: 'Issue: cannot reproduce',
24 | description: "Not reproducible in the latest release. Please re-open this issue if it still occurs, with a JSBin containing a set of reproducible steps. Check this element's CONTRIBUTING.md for an example."
25 | },
26 | {
27 | name: 'PR: thanks! looking!',
28 | description: "Thanks for your contribution! We'll triage and take a look at it as soon as possible!"
29 | },
30 | {
31 | name: 'PR: needs test',
32 | description: "Please add a test case that tests the problem this PR is fixing."
33 | }
34 | ];
35 |
36 | function getAnswersListFromStorage() {
37 | // Load the answers from local storage.
38 | var localStorageKey = '__GH_CANNED_ANSWERS__EXT__';
39 | var saved = localStorage.getItem(localStorageKey);
40 | var answers;
41 |
42 | if (!saved || saved === '') {
43 | localStorage.setItem(localStorageKey, JSON.stringify(defaultAnswers));
44 | answers = defaultAnswers;
45 | } else {
46 | answers = JSON.parse(localStorage.getItem(localStorageKey));
47 | }
48 | return answers;
49 | }
50 |
51 | chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
52 | if (request.action === 'load') {
53 | var answers = getAnswersListFromStorage();
54 | sendResponse({'answers': answers});
55 | }
56 | if (request.action === 'save') {
57 | // Save the answers to local storage.
58 | var localStorageKey = '__GH_CANNED_ANSWERS__EXT__';
59 | localStorage.setItem(localStorageKey, JSON.stringify(request.answers));
60 | sendResponse();
61 | }
62 | });
63 |
--------------------------------------------------------------------------------
/src/contentScript.js:
--------------------------------------------------------------------------------
1 | var __gcrExtAnswers;
2 |
3 | (function() {
4 | "use strict";
5 |
6 | // This following code is taken from
7 | // https://github.com/thieman/github-selfies/blob/master/chrome/selfie.js
8 | var allowedPaths = [
9 | // New issues
10 | /github.com\/[\w\-]+\/[\w\-]+\/issues\/new/,
11 | // Existing issues (comment)
12 | /github.com\/[\w\-]+\/[\w\-]+\/issues\/\d+/,
13 | // New pull request
14 | /github.com\/[\w\-]+\/[\w\-]+\/compare/,
15 | // Existing pull requests (comment)
16 | /github.com\/[\w\-]+\/[\w\-]+\/pull\/\d+/
17 | ];
18 |
19 | // Inject the code from fn into the page, in an IIFE.
20 | function inject(fn) {
21 | var script = document.createElement('script');
22 | var parent = document.documentElement;
23 | script.textContent = '('+ fn +')();';
24 | parent.appendChild(script);
25 | parent.removeChild(script);
26 | }
27 |
28 | // Post a message whenever history.pushState is called. GitHub uses
29 | // pushState to implement page transitions without full page loads.
30 | // This needs to be injected because content scripts run in a sandbox.
31 | inject(function() {
32 | var pushState = history.pushState;
33 | history.pushState = function on_pushState() {
34 | window.postMessage('extension:pageUpdated', '*');
35 | return pushState.apply(this, arguments);
36 | };
37 | var replaceState = history.replaceState;
38 | history.replaceState = function on_replaceState() {
39 | window.postMessage('extension:pageUpdated', '*');
40 | return replaceState.apply(this, arguments);
41 | };
42 | });
43 |
44 | // Do something when the extension is loaded into the page,
45 | // and whenever we push/pop new pages.
46 | window.addEventListener("message", function(event) {
47 | if (event.data === 'extension:pageUpdated') {
48 | addAnswerButton();
49 | }
50 | });
51 |
52 | window.addEventListener("popstate", load);
53 | load();
54 |
55 | // End of code from https://github.com/thieman/github-selfies/blob/master/chrome/selfie.js
56 |
57 | function load() {
58 | chrome.runtime.sendMessage({action: 'load'}, function(response) {
59 | __gcrExtAnswers = response.answers;
60 | addAnswerButton();
61 | });
62 | }
63 |
64 | function any(array, predicate) {
65 | for (var i = 0; i < array.length; i++) {
66 | if (predicate(array[i])) {
67 | return true;
68 | }
69 | }
70 | return false;
71 | }
72 |
73 | function addAnswerButton() {
74 | if (!any(allowedPaths, (path) => path.test(window.location.href))) {
75 | // NOPE.
76 | return;
77 | }
78 |
79 | // If there's already a button nuke it so we can start fresh.
80 | var existingButtons = document.querySelectorAll('.github-canned-response-item');
81 | if (existingButtons && existingButtons.length !== 0) {
82 | for (var i = 0; i < existingButtons.length; i++) {
83 | existingButtons[i].parentNode.removeChild(existingButtons[i]);
84 | }
85 | }
86 |
87 | var targets = document.querySelectorAll('.js-toolbar.toolbar-commenting');
88 |
89 | for (var i = 0; i < targets.length; i++) {
90 | var target = createNodeWithClass('div', 'toolbar-group github-canned-response-item');
91 | targets[i].insertBefore(target, targets[i].childNodes[0]);
92 |
93 | var item = createNodeWithClass('div', 'select-menu js-menu-container js-select-menu label-select-menu');
94 | target.appendChild(item);
95 |
96 | var button = createButton();
97 | item.appendChild(button);
98 |
99 | if (targets[i]) {
100 | var dropdown = createDropdown(__gcrExtAnswers, targets[i]);
101 | item.appendChild(dropdown);
102 | }
103 | }
104 | }
105 |
106 | function createNodeWithClass(nodeType, className) {
107 | var element = document.createElement(nodeType);
108 | element.className = className;
109 | return element;
110 | }
111 |
112 | function createButton() {
113 | var button = createNodeWithClass('button', 'toolbar-item tooltipped tooltipped-n js-menu-target menu-target');
114 |
115 | button.setAttribute('aria-label', 'Insert canned response');
116 | button.style.display = 'inline-block';
117 | button.type = 'button';
118 |
119 | // Github just shipped svg icons!
120 | var svg = createSVG(18, 16, 'octicon-mail-read', "M6 5H4v-1h2v1z m3 1H4v1h5v-1z m5-0.48v8.48c0 0.55-0.45 1-1 1H1c-0.55 0-1-0.45-1-1V5.52c0-0.33 0.16-0.63 0.42-0.81l1.58-1.13v-0.58c0-0.55 0.45-1 1-1h1.2L7 0l2.8 2h1.2c0.55 0 1 0.45 1 1v0.58l1.58 1.13c0.27 0.19 0.42 0.48 0.42 0.81zM3 7.5l4 2.5 4-2.5V3H3v4.5zM1 13.5l4.5-3L1 7.5v6z m11 0.5L7 11 2 14h10z m1-6.5L8.5 10.5l4.5 3V7.5z");
121 | button.appendChild(svg);
122 | var span = createNodeWithClass('span', 'dropdown-caret');
123 | button.appendChild(span);
124 |
125 | return button;
126 | }
127 |
128 | function createDropdown(answers, toolbar) {
129 | // This should use the fuzzy search instead (see labels)
130 | var outer = createNodeWithClass('div', 'select-menu-modal-holder js-menu-content js-navigation-container');
131 | var inner = createNodeWithClass('div', 'select-menu-modal');
132 | outer.appendChild(inner);
133 |
134 | // I hate the DOM.
135 | var header = createNodeWithClass('div', 'select-menu-header');
136 | var headerSpan = createNodeWithClass('span', 'select-menu-title');
137 | var spanText = document.createElement('text');
138 | spanText.innerHTML = 'Canned responses ';
139 |
140 | var editButton = createNodeWithClass('button', 'btn-link github-canned-response-edit');
141 | editButton.type = 'button';
142 | editButton.innerHTML = '(edit or add new)';
143 | editButton.addEventListener('click', showEditView);
144 |
145 | headerSpan.appendChild(spanText);
146 | headerSpan.appendChild(editButton);
147 | header.appendChild(headerSpan);
148 | inner.appendChild(header);
149 |
150 | var main = createNodeWithClass('div', 'js-select-menu-deferred-content');
151 | inner.appendChild(main);
152 |
153 | var filter = createNodeWithClass('div', 'select-menu-filters');
154 | var filterText = createNodeWithClass('div', 'select-menu-text-filter');
155 | var filterInput = createNodeWithClass('input', 'js-filterable-field js-navigation-enable form-control');
156 | var uniqueID = toolbar.closest('form').querySelector('textarea').id;
157 | filterInput.id = 'gcr-ext-filter-field' + uniqueID;
158 | filterInput.type = 'text';
159 | filterInput.placeholder = 'Filter responses';
160 | filterInput.autocomplete = 'off';
161 | filterInput.setAttribute('aria-label', 'Type or choose a response');
162 |
163 | filterText.appendChild(filterInput);
164 | filter.appendChild(filterText);
165 | main.appendChild(filter);
166 |
167 | var itemList = createNodeWithClass('div', 'select-menu-list');
168 | itemList.setAttribute('data-filterable-for', 'gcr-ext-filter-field' + uniqueID);
169 | itemList.setAttribute('data-filterable-type', 'fuzzy');
170 |
171 | main.appendChild(itemList);
172 |
173 | for (var i = 0; i < answers.length; i++) {
174 | var item = createDropdownItem(answers[i].name);
175 | itemList.appendChild(item);
176 | item.toolbar = toolbar;
177 | item.answer = answers[i].description;
178 | item.addEventListener('click', insertAnswer);
179 |
180 | // Gigantic hack because the PR page is not setting up mouse events correctly.
181 | item.addEventListener('mouseenter', function() {
182 | this.className += ' navigation-focus';
183 | });
184 | item.addEventListener('mouseleave', function() {
185 | this.className = this.className.replace(/ navigation-focus/g, '');
186 | });
187 | }
188 |
189 | return outer;
190 | }
191 |
192 | function createDropdownItem(text) {
193 | var item = createNodeWithClass('div', 'select-menu-item js-navigation-item');
194 | item.textContent = text;
195 | return item;
196 | }
197 |
198 | function createSVG(height, width, octiconName, octiconPath) {
199 | var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
200 | svg.setAttribute('class', 'octicon ' + octiconName);
201 | svg.style.height = height + 'px';
202 | svg.style.width = width + 'px';
203 | svg.setAttribute('viewBox', '0 0 ' + height + ' ' + width);
204 |
205 | var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
206 | path.setAttributeNS(null, 'd', octiconPath);
207 | svg.appendChild(path);
208 |
209 | return svg;
210 | }
211 |
212 | function insertAnswer(event) {
213 | var item = event.target;
214 | var textarea = item.toolbar.parentNode.parentNode.querySelector('textarea');
215 | textarea.value += item.answer + '\n';
216 |
217 | // Scroll down.
218 | textarea.focus();
219 | textarea.scrollTop = textarea.scrollHeight;
220 | }
221 |
222 | function showEditView() {
223 |
224 | var dialog = createNodeWithClass('div', 'gcr-ext-editor-dialog');
225 | dialog.id = 'gcr-ext-editor';
226 |
227 | // lol. This is from options.html.
228 | // TODO: Replace this with ES6 civilized strings when you're less scared
229 | // about breaking everything.
230 | dialog.innerHTML = '