├── .babelrc ├── w3c.json ├── application ├── dispatcher.js ├── logout-button.jsx ├── welcome.jsx ├── utils.js ├── admin │ ├── pick-user.jsx │ ├── group-line.jsx │ ├── add-user.jsx │ ├── users.jsx │ ├── user-line.jsx │ ├── groups.jsx │ └── edit-user.jsx ├── login.jsx ├── contributors-list.jsx ├── repo-list.jsx ├── pr │ ├── open.jsx │ ├── last-week.jsx │ └── viewer.jsx └── repo-manager.jsx ├── public ├── fonts │ ├── titilliumweb-light-webfont.eot │ ├── titilliumweb-light-webfont.ttf │ ├── titilliumweb-light-webfont.woff │ ├── titilliumweb-light-webfont.woff2 │ ├── titilliumweb-semibold-webfont.eot │ ├── titilliumweb-semibold-webfont.ttf │ ├── titilliumweb-extralight-webfont.eot │ ├── titilliumweb-extralight-webfont.ttf │ ├── titilliumweb-extralight-webfont.woff │ ├── titilliumweb-lightitalic-webfont.eot │ ├── titilliumweb-lightitalic-webfont.ttf │ ├── titilliumweb-semibold-webfont.woff │ ├── titilliumweb-semibold-webfont.woff2 │ ├── titilliumweb-extralight-webfont.woff2 │ ├── titilliumweb-lightitalic-webfont.woff │ ├── titilliumweb-lightitalic-webfont.woff2 │ ├── titilliumweb-semibolditalic-webfont.eot │ ├── titilliumweb-semibolditalic-webfont.ttf │ ├── titilliumweb-semibolditalic-webfont.woff │ └── titilliumweb-semibolditalic-webfont.woff2 ├── testing │ ├── ok.html │ └── gh.html ├── img │ └── spinner.svg └── css │ └── app.min.css ├── templates ├── w3c.json ├── README.md ├── WG-LICENSE.md ├── WG-LICENSE-SW.md ├── CODE_OF_CONDUCT.md ├── CG-license.md ├── app.html ├── CG-contributing.md ├── WG-CONTRIBUTING.md ├── WG-CONTRIBUTING-SW.md ├── index.html └── affiliation-mail.txt ├── components ├── nav-item.jsx ├── row.jsx ├── col.jsx ├── nav-box.jsx ├── spinner.jsx ├── application.jsx └── flash-list.jsx ├── pm2-production.json ├── actions ├── user.js └── messages.js ├── tools └── add-admin.js ├── template.js ├── test └── config-test.json ├── .gitignore ├── .github └── workflows │ └── node.js.yml ├── LICENSE ├── log.js ├── stores ├── message.js └── login.js ├── notification.js ├── w3c-ipr.js ├── webhook-update.js ├── css └── fonts.css ├── package.json ├── app.css ├── app.jsx ├── README.md ├── pr-check.js ├── gh.js └── DEVELOPMENT.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /w3c.json: -------------------------------------------------------------------------------- 1 | { 2 | "contacts": ["dontcallmedom", "tripu"], 3 | "repo-type": "tool" 4 | } 5 | -------------------------------------------------------------------------------- /application/dispatcher.js: -------------------------------------------------------------------------------- 1 | 2 | var Dispatcher = require("flux").Dispatcher; 3 | module.exports = new Dispatcher(); 4 | -------------------------------------------------------------------------------- /public/fonts/titilliumweb-light-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-light-webfont.eot -------------------------------------------------------------------------------- /public/fonts/titilliumweb-light-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-light-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/titilliumweb-light-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-light-webfont.woff -------------------------------------------------------------------------------- /templates/w3c.json: -------------------------------------------------------------------------------- 1 | { 2 | "group": {{w3cid}} 3 | , "contacts": {{usernames}} 4 | , "repo-type": "{{repotype}}" 5 | } 6 | -------------------------------------------------------------------------------- /public/fonts/titilliumweb-light-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-light-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibold-webfont.eot -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibold-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/titilliumweb-extralight-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-extralight-webfont.eot -------------------------------------------------------------------------------- /public/fonts/titilliumweb-extralight-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-extralight-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/titilliumweb-extralight-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-extralight-webfont.woff -------------------------------------------------------------------------------- /public/fonts/titilliumweb-lightitalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-lightitalic-webfont.eot -------------------------------------------------------------------------------- /public/fonts/titilliumweb-lightitalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-lightitalic-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibold-webfont.woff -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibold-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/titilliumweb-extralight-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-extralight-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/titilliumweb-lightitalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-lightitalic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/titilliumweb-lightitalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-lightitalic-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibolditalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibolditalic-webfont.eot -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibolditalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibolditalic-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibolditalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibolditalic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/titilliumweb-semibolditalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3c/ash-nazg/master/public/fonts/titilliumweb-semibolditalic-webfont.woff2 -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Specification '{{repo}}' 3 | 4 | This is the repository for {{repo}}. You're welcome to contribute! Let's make the Web rock our socks 5 | off! 6 | -------------------------------------------------------------------------------- /templates/WG-LICENSE.md: -------------------------------------------------------------------------------- 1 | All documents in this Repository are licensed by contributors 2 | under the 3 | [W3C Document License](https://www.w3.org/copyright/document-license/). 4 | 5 | -------------------------------------------------------------------------------- /templates/WG-LICENSE-SW.md: -------------------------------------------------------------------------------- 1 | All documents in this Repository are licensed by contributors 2 | under the 3 | [W3C Software and Document License](https://www.w3.org/copyright/software-license/). 4 | 5 | -------------------------------------------------------------------------------- /components/nav-item.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | export default class NavItem extends React.Component { 5 | render () { 6 | return
  • {this.props.children}
  • ; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /templates/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | All documentation, code and communication under this repository are covered by the [W3C Code of Conduct](https://www.w3.org/policies/code-of-conduct/). 4 | -------------------------------------------------------------------------------- /public/testing/ok.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ok! 6 | 7 | 8 | Login ok! 9 | 10 | 11 | -------------------------------------------------------------------------------- /components/row.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | // this is basically ungrid in a box 5 | export default class Row extends React.Component { 6 | render () { 7 | return
    {this.props.children}
    ; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/testing/gh.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GitHub Login 6 | 7 | 8 | Go log in using GitHub! 9 | 10 | 11 | -------------------------------------------------------------------------------- /pm2-production.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ashnazg" 3 | , "script": "./server.js" 4 | , "args": [] 5 | , "exec_mode": "fork" 6 | , "instances": 1 7 | , "out_file": "/home/ubuntu/lab/ashnazg-pm2.log" 8 | , "error_file": "/home/ubuntu/lab/ashnazg-pm2-errors.log" 9 | } -------------------------------------------------------------------------------- /components/col.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | // this is basically ungrid in a box 5 | export default class Col extends React.Component { 6 | render () { 7 | return
    {this.props.children}
    ; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /actions/user.js: -------------------------------------------------------------------------------- 1 | 2 | import AshNazgDispatch from "../application/dispatcher"; 3 | 4 | module.exports = { 5 | login: function () { 6 | AshNazgDispatch.dispatch({ type: "login" }); 7 | } 8 | , logout: function () { 9 | AshNazgDispatch.dispatch({ type: "logout" }); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /application/logout-button.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import UserActions from "../actions/user"; 4 | 5 | export default class LogoutButton extends React.Component { 6 | handleClick () { 7 | UserActions.logout(); 8 | } 9 | render () { 10 | return ; 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /components/nav-box.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | export default class NavBox extends React.Component { 5 | render () { 6 | return ; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /templates/CG-license.md: -------------------------------------------------------------------------------- 1 | All Reports in this Repository are licensed by Contributors 2 | under the 3 | [W3C Software and Document License](https://www.w3.org/copyright/software-license/). 4 | 5 | Contributions to Specifications are made under the 6 | [W3C CLA](https://www.w3.org/community/about/process/cla/). 7 | 8 | Contributions to Test Suites are made under the 9 | [W3C 3-clause BSD License](https://www.w3.org/copyright/3-clause-bsd-license-2008/) 10 | 11 | -------------------------------------------------------------------------------- /tools/add-admin.js: -------------------------------------------------------------------------------- 1 | 2 | var Store = require("../store") 3 | , username = process.argv[2] 4 | , config = process.argv[3] || "config.json" 5 | , die = function (msg) { 6 | console.error(msg); 7 | process.exit(1); 8 | } 9 | ; 10 | if (!username) die("Usage: node tools/add-admin.js username [configfile]"); 11 | new Store(require("../" + config)).makeUserAdmin(username, function (err) { 12 | if (err) die("ERROR: " + err); 13 | console.log("Ok!"); 14 | }); 15 | -------------------------------------------------------------------------------- /components/spinner.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | // a very simple spinner 5 | export default class Spinner extends React.Component { 6 | render () { 7 | let size = 52 8 | , prefix = "/"; 9 | if (this.props.size === "small") size /= 2; 10 | if (this.props.prefix) prefix = this.props.prefix; 11 | return
    loading...
    ; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /template.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | , jn = require("path").join; 3 | 4 | module.exports = function template (src, data) { 5 | return fs.readFileSync(jn(__dirname, "templates", src), "utf8") 6 | .replace(/\{\{(\w+)\}\}/g, function (_, k) { 7 | if (typeof data[k] === "undefined") { 8 | console.error("No template data for key=" + k + ", file=" + src); 9 | return ""; 10 | } 11 | return data[k]; 12 | }); 13 | } 14 | 15 | -------------------------------------------------------------------------------- /actions/messages.js: -------------------------------------------------------------------------------- 1 | 2 | import AshNazgDispatch from "../application/dispatcher"; 3 | 4 | module.exports = { 5 | error: function (msg) { 6 | console.error(msg); 7 | AshNazgDispatch.dispatch({ type: "error", message: msg }); 8 | } 9 | , success: function (msg) { 10 | console.log(msg); 11 | AshNazgDispatch.dispatch({ type: "success", message: msg }); 12 | } 13 | , dismiss: function (id) { 14 | AshNazgDispatch.dispatch({ type: "dismiss", id: id }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /components/application.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | export default class Application extends React.Component { 5 | render () { 6 | return
    7 |

    {this.props.title}

    8 |
    {this.props.children}
    9 | 10 |
    11 | ; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /application/welcome.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | export default class Welcome extends React.Component { 5 | render () { 6 | return
    7 |

    Welcome!

    8 |

    9 | Use this site to manage IPR of contributions made to GitHub repositories for W3C specifications 10 | . 11 |

    12 |
    13 | ; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /test/config-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "http://localhost:8888/" 3 | , "hookURL": "http://localhost:8888/api/hook" 4 | , "hookPath": "api/hook" 5 | , "serverPort": 8888 6 | , "sessionSecret": "I'm a secret" 7 | , "ghClientID": "1234" 8 | , "ghClientSecret": "5678" 9 | , "logToConsole": false 10 | , "logToFile": "test/logs" 11 | , "couchDB": "ashnazg-test" 12 | , "couchAuth": { 13 | "username": "admin", 14 | "password": "password" 15 | } 16 | , "notifyFrom": "test@localhost.test" 17 | , "w3cBotGHToken": "123" 18 | } 19 | -------------------------------------------------------------------------------- /templates/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | W3C Repository Manager 6 | 7 | 8 | 9 |
    10 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | scratch 30 | config.json 31 | data 32 | 33 | # Node.js 8 new shrinkwrap file: 34 | package-lock.json 35 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: ash-nazg tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [20, 22, 24] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Start CouchDB 19 | uses: iamssen/couchdb-github-action@0.3.0 20 | with: 21 | couchdb-version: 3.1 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm i 27 | - run: cp ./test/config-test.json ./config.json 28 | - run: node store.js "./test/config-test.json" 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /templates/CG-contributing.md: -------------------------------------------------------------------------------- 1 | # {{name}} 2 | 3 | This repository is being used for work in the W3C {{name}}, governed by the [W3C Community License 4 | Agreement (CLA)](http://www.w3.org/community/about/process/cla/). To make substantive contributions, 5 | you must join the CG. 6 | 7 | If you are not the sole contributor to a contribution (pull request), please identify all 8 | contributors in the pull request comment. 9 | 10 | To add a contributor (other than yourself, that's automatic), mark them one per line as follows: 11 | 12 | ``` 13 | +@github_username 14 | ``` 15 | 16 | If you added a contributor by mistake, you can remove them in a comment with: 17 | 18 | ``` 19 | -@github_username 20 | ``` 21 | 22 | If you are making a pull request on behalf of someone else but you had no part in designing the 23 | feature, you can remove yourself with the above syntax. 24 | -------------------------------------------------------------------------------- /templates/WG-CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # {{name}} 2 | 3 | Contributions to this repository are intended to become part of Recommendation-track documents governed by the 4 | [W3C Patent Policy](https://www.w3.org/policies/patent-policy/) and 5 | [Document License](https://www.w3.org/copyright/document-license/). To make substantive contributions to specifications, you must either participate 6 | in the relevant W3C Working Group or make a non-member patent licensing commitment. 7 | 8 | If you are not the sole contributor to a contribution (pull request), please identify all 9 | contributors in the pull request comment. 10 | 11 | To add a contributor (other than yourself, that's automatic), mark them one per line as follows: 12 | 13 | ``` 14 | +@github_username 15 | ``` 16 | 17 | If you added a contributor by mistake, you can remove them in a comment with: 18 | 19 | ``` 20 | -@github_username 21 | ``` 22 | 23 | If you are making a pull request on behalf of someone else but you had no part in designing the 24 | feature, you can remove yourself with the above syntax. 25 | -------------------------------------------------------------------------------- /templates/WG-CONTRIBUTING-SW.md: -------------------------------------------------------------------------------- 1 | # {{name}} 2 | 3 | Contributions to this repository are intended to become part of Recommendation-track documents governed by the 4 | [W3C Patent Policy](https://www.w3.org/policies/patent-policy/) and 5 | [Software and Document License](https://www.w3.org/copyright/software-license/). To make substantive contributions to specifications, you must either participate 6 | in the relevant W3C Working Group or make a non-member patent licensing commitment. 7 | 8 | If you are not the sole contributor to a contribution (pull request), please identify all 9 | contributors in the pull request comment. 10 | 11 | To add a contributor (other than yourself, that's automatic), mark them one per line as follows: 12 | 13 | ``` 14 | +@github_username 15 | ``` 16 | 17 | If you added a contributor by mistake, you can remove them in a comment with: 18 | 19 | ``` 20 | -@github_username 21 | ``` 22 | 23 | If you are making a pull request on behalf of someone else but you had no part in designing the 24 | feature, you can remove yourself with the above syntax. 25 | -------------------------------------------------------------------------------- /application/utils.js: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import MessageActions from "../actions/messages"; 4 | 5 | let pathPrefix; 6 | 7 | module.exports = { 8 | jsonHandler: (res) => { return res.json(); } 9 | , catchHandler: (e) => { 10 | MessageActions.error(e); 11 | } 12 | , pathPrefix: () => { 13 | if (!pathPrefix) 14 | pathPrefix = PREFIX; 15 | return pathPrefix; 16 | } 17 | , val: (ref) => { 18 | let el = ref 19 | , value 20 | ; 21 | if (!el) return null; 22 | if (el.multiple) { 23 | value = []; 24 | for (var i = 0, n = el.selectedOptions.length; i < n; i++) { 25 | value.push(el.selectedOptions.item(i).value.trim()); 26 | } 27 | } 28 | else value = el.value.trim(); 29 | return value; 30 | } 31 | , andify: (list, conjunction = "and") => { 32 | if (list.length === 1) return list[0]; 33 | return list.slice(0, -1).join(", ") + " " + conjunction + " " + list.slice(-1); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Spec proposal 6 | 7 | 17 | 18 | 19 |
    20 |

    21 | This specification does neat stuff. 22 |

    23 |
    24 |
    25 |

    26 | This is an unofficial proposal. 27 |

    28 |
    29 | 30 |
    31 |

    Introduction

    32 |

    33 | See ReSpec's user guide 34 | for how toget started! 35 |

    36 |
    37 | 38 | 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 World Wide Web Consortium 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 | 23 | -------------------------------------------------------------------------------- /log.js: -------------------------------------------------------------------------------- 1 | 2 | var winston = require("winston") 3 | , transports = [] 4 | ; 5 | 6 | var logger; 7 | 8 | module.exports = function(config) { 9 | if (!logger) { 10 | // logging 11 | if (config.logToConsole) { 12 | transports.push( 13 | new (winston.transports.Console)({ 14 | handleExceptions: true 15 | , colorize: true 16 | , maxsize: 200000000 17 | , humanReadableUnhandledException: true 18 | }) 19 | ); 20 | } 21 | if (config.logToFile) { 22 | transports.push( 23 | new (winston.transports.File)({ 24 | filename: config.logToFile 25 | , handleExceptions: true 26 | , timestamp: true 27 | , humanReadableUnhandledException: true 28 | }) 29 | ); 30 | } 31 | 32 | logger = new (winston.Logger)({ transports: transports }); 33 | } 34 | return logger; 35 | }; 36 | -------------------------------------------------------------------------------- /stores/message.js: -------------------------------------------------------------------------------- 1 | 2 | import AshNazgDispatch from "../application/dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | 6 | let _messages = [] 7 | , _counter = 0 8 | , MessageStore = module.exports = assign({}, EventEmitter.prototype, { 9 | emitChange: function () { this.emit("change"); } 10 | , addChangeListener: function (cb) { this.on("change", cb); } 11 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 12 | 13 | , messages: function () { 14 | return _messages; 15 | } 16 | }) 17 | ; 18 | 19 | MessageStore.dispatchToken = AshNazgDispatch.register((action) => { 20 | switch (action.type) { 21 | case "error": 22 | case "success": 23 | let msg = typeof action.message === "string" ? action.message : action.message.message; 24 | _messages.push({ 25 | id: ++_counter 26 | , message: msg 27 | , type: action.type 28 | }); 29 | MessageStore.emitChange(); 30 | break; 31 | case "dismiss": 32 | _messages = _messages.filter(function (m) { return m.id != action.id; }); 33 | MessageStore.emitChange(); 34 | break; 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /application/admin/pick-user.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import { Link } from "react-router"; 4 | 5 | let utils = require("../../application/utils") 6 | , pp = utils.pathPrefix() 7 | ; 8 | 9 | export default class PickUser extends React.Component { 10 | constructor (props) { 11 | super(props); 12 | this.state = { 13 | username: null 14 | }; 15 | } 16 | 17 | handleChange () { 18 | this.setState({ username: utils.val(this.refs.username) }); 19 | } 20 | 21 | render () { 22 | let st = this.state 23 | , link = typeof st.username === "string" && st.username.length ? 24 | Pick 25 | : 26 | null 27 | ; 28 | return
    29 |

    Provide a GitHub user name to add the user

    30 |
    31 | 32 | 33 | {" "} 34 | {link} 35 |
    36 |
    37 | ; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /templates/affiliation-mail.txt: -------------------------------------------------------------------------------- 1 | Dear {{displayName}} 2 | 3 | Thank you for submitting a pull request (PR #{{prnum}}) on the W3C specification repository {{repo}}. 4 | https://github.com/{{repo}}/pull/{{prnum}} 5 | 6 | To ensure that the Web can be used and developed by anyone free of charge, W3C develops specifications under a Royalty-Free Patent Policy: 7 | https://www.w3.org/policies/patent-policy/ 8 | 9 | As part of this policy, W3C groups must be able to assess the IPR context of contributions made to their repositories. 10 | 11 | As our automated tool was not able to determine with what organization (if any) you are affiliated, we would be very grateful if you could see which of the following applies to you: 12 | 13 | * if your contribution does not concern a normative part of a specification, or is editorial in nature (e.g. fixing typos or examples), you don't need to do anything 14 | 15 | * if you are a member of the group that maintains this repository, please link your W3C and github accounts together at 16 | https://www.w3.org/users/myprofile/connectedaccounts/ 17 | 18 | * if you work for a W3C Member organization, please get a W3C account at 19 | https://www.w3.org/account/request/ 20 | once that is done, or if you already have a W3C account, please link your W3C and github accounts together at 21 | https://www.w3.org/users/myprofile/connectedaccounts/ 22 | 23 | * otherwise, please contact {{contacts}} to see how to proceed with your contribution. 24 | 25 | Thanks again for your contribution. If any of this is unclear, please contact sysreq@w3.org or {{contacts}} for assistance. 26 | 27 | -- 28 | W3C automated IPR checker 29 | -------------------------------------------------------------------------------- /components/flash-list.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | // a very simple flash error 5 | export default class FlashList extends React.Component { 6 | // receive a store to listen to 7 | // when it changes, get messages from there 8 | constructor (props) { 9 | super(props); 10 | this.state = { messages: [] }; 11 | } 12 | componentDidMount () { 13 | this.props.store.addChangeListener(this._onChange.bind(this)); 14 | } 15 | componentWillUnmount () { 16 | this.props.store.removeChangeListener(this._onChange.bind(this)); 17 | } 18 | _onChange () { 19 | this.setState({ messages: this.props.store.messages() }); 20 | } 21 | dismiss (id) { 22 | this.props.actions.dismiss(id); 23 | } 24 | 25 | render () { 26 | let st = this.state 27 | , messages = st.messages || [] 28 | ; 29 | return
    30 | { 31 | messages.map( 32 | (msg) => { 33 | return
    34 | 35 |

    36 | {msg.message} 37 |

    38 |
    39 | ; 40 | } 41 | ) 42 | } 43 |
    44 | ; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /application/login.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | require("isomorphic-fetch"); 5 | let utils = require("./utils") 6 | , pp = utils.pathPrefix() 7 | ; 8 | 9 | 10 | export default class LoginWelcome extends React.Component { 11 | constructor (props) { 12 | super(props); 13 | this.state = { 14 | login: null 15 | }; 16 | } 17 | 18 | componentWillMount () { 19 | let st = this.state; 20 | fetch(pp + "api/logged-in", { credentials: "include" }) 21 | .then(utils.jsonHandler) 22 | .then((data) => { 23 | this.setState(Object.assign({}, st, {login: data.login})); 24 | }) 25 | .catch(utils.catchHandler); 26 | } 27 | 28 | render () { 29 | let redir = document.location.href; 30 | return this.state.login ?

    Logged in

    You are logged in as {this.state.login}.

    : 31 |
    32 |

    Please login

    33 |

    34 | This site is essentially an application built on top of GitHub. As such, 35 | in order for it to work, you need to log into it using your GitHub 36 | credentials. 37 |

    38 |

    39 | Pull requests contributors can log in using GitHub. 40 |

    41 |

    42 | Note: People who wish to import repositories should use this link to log in as we require more permissions. 43 |

    44 |
    45 | ; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /notification.js: -------------------------------------------------------------------------------- 1 | const template = require("./template"); 2 | const doAsync = require('doasync') // rather than utils.promisy to get "free" support for object methods 3 | 4 | exports.notifyContacts = async function (gh, pr, status, mailer, emailConfig, store, log) { 5 | log.info("Attempting to notify error on " + pr.fullName); 6 | let actualEmails, emails; 7 | try { 8 | emails = await gh.getRepoContacts(pr.fullName); 9 | } catch (err) { 10 | log.error(err); 11 | } 12 | if (!emails) { 13 | actualEmails = emailConfig.fallback; 14 | } else { 15 | actualEmails = emails.filter(function(e) { return e !== null;}); 16 | if (!actualEmails || !actualEmails.length) { 17 | log.error("Could not retrieve email addresses from repo contacts for " + pr.fullName); 18 | actualEmails = emailConfig.fallback; 19 | } 20 | } 21 | await doAsync(mailer).sendMail({ 22 | from: emailConfig.from, 23 | to: actualEmails.join(","), 24 | cc: emailConfig.cc.join(","), 25 | subject: `IPR check failed for PR #${pr.num} on ${pr.fullName}`, 26 | text: `${status.payload.description}\n\nSee ${status.payload.target_url}\nand https://github.com/${pr.fullName}/pull/${pr.num}` 27 | }); 28 | for await (let user of pr.unaffiliatedUsers 29 | .map(u => doAsync(store).getUser(u))) { 30 | if (user.emails.length) { 31 | const mailData = { 32 | displayName: user.displayName ? user.displayName : user.login, 33 | prnum: pr.num, 34 | repo: pr.fullName, 35 | contacts: actualEmails.join(",") 36 | }; 37 | return doAsync(mailer).sendMail({ 38 | from: emailConfig.from, 39 | to: user.emails[0].value, 40 | cc: [...actualEmails, ...emailConfig.cc].join(","), 41 | subject: "Information needed for your PR #" + pr.num+ " on " + pr.fullName, 42 | text: template('affiliation-mail.txt', mailData) 43 | }); 44 | } 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /w3c-ipr.js: -------------------------------------------------------------------------------- 1 | const doAsync = require('doasync'); // rather than utils.promisy to get "free" support for object methods 2 | 3 | const fromUrlToId = url => url.replace(/.*\//, ""); 4 | 5 | module.exports = async function iprcheck(w3c, w3cprofileid, name, w3cgroups, store, cb) { 6 | for (let {id, type, shortname} of w3cgroups) { 7 | const group = await doAsync(store).getGroup(id); 8 | const participations = await w3c.user(w3cprofileid) 9 | .participations() 10 | .fetch({embed: true}); 11 | if (!participations) return {affiliation: null, ok: false}; 12 | for (let p of participations) { 13 | const org = p._links.organization; 14 | const affiliation = p.individual ? {id: w3cprofileid, name: name} : {id: fromUrlToId(org.href), name: org.title}; 15 | if (p._links.group.href === `https://api.w3.org/groups/${type}/${shortname}`) { 16 | return {ok: true, affiliation: affiliation}; 17 | } 18 | } 19 | // If we reach here, 20 | // the user is not participating directly in the group 21 | // For non WGs, game over 22 | if (group.groupType != "WG") { 23 | continue; 24 | } 25 | // For WGs, we check if the user is affiliated 26 | // with an organization that is participating 27 | const orgParticipations = await w3c.group({type, shortname}) 28 | .participations() 29 | .fetch({embed: true}); 30 | const orgids = orgParticipations 31 | .filter(p => !p.individual) 32 | .map(p => fromUrlToId(p._links.organization.href)); 33 | const affiliations = await w3c.user(w3cprofileid) 34 | .affiliations() 35 | .fetch(); 36 | const affids = affiliations.map(a => a ? fromUrlToId(a.href) : undefined); 37 | const intersection = orgids.filter(id => affids.includes(id)); 38 | if (intersection.length) { 39 | const affiliationId = intersection[0]; 40 | const affiliationName = affiliations.find(a => a.href == "https://api.w3.org/affiliations/" + affiliationId).title; 41 | return {ok: true, affiliation: {id: affiliationId, name: affiliationName}}; 42 | } 43 | } 44 | return {affiliation: null, ok: false}; 45 | }; 46 | -------------------------------------------------------------------------------- /application/admin/group-line.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import MessageActions from "../../actions/messages"; 4 | 5 | require("isomorphic-fetch"); 6 | let utils = require("../../application/utils") 7 | , pp = utils.pathPrefix() 8 | ; 9 | 10 | 11 | export default class GroupLine extends React.Component { 12 | constructor (props) { 13 | super(props); 14 | this.state = { managed: props.managed }; 15 | } 16 | makeManaged () { 17 | this.refs.managed.disabled = true; 18 | var p = this.props; 19 | fetch( 20 | pp + "api/groups" 21 | , { 22 | method: "post" 23 | , headers: { "Content-Type": "application/json" } 24 | , credentials: "include" 25 | , body: JSON.stringify({ 26 | w3cid: p.w3cid 27 | , name: p.name 28 | , groupType: p.groupType 29 | }) 30 | } 31 | ) 32 | .then(utils.jsonHandler) 33 | .then((data) => { 34 | if (data.ok) { 35 | MessageActions.success("Group now managed under the system."); 36 | return this.setState({ managed: true }); 37 | } 38 | this.refs.managed.disabled = false; 39 | MessageActions.error("Failure to add group to those managed: " + data.error); 40 | }) 41 | .catch(utils.catchHandler) 42 | ; 43 | } 44 | render () { 45 | let props = this.props 46 | , st = this.state 47 | , makeManaged = 48 | , tdStyle = { paddingRight: "20px" } 49 | ; 50 | if (!st.managed) makeManaged = ; 51 | return 52 | {props.groupType} 53 | {props.name} 54 | {props.w3cid} 55 | {makeManaged} 56 | 57 | ; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /stores/login.js: -------------------------------------------------------------------------------- 1 | 2 | import AshNazgDispatch from "../application/dispatcher"; 3 | import EventEmitter from "events"; 4 | import assign from "object-assign"; 5 | 6 | // /!\ magically create a global fetch 7 | require("isomorphic-fetch"); 8 | 9 | let utils = require("../application/utils") 10 | , pp = utils.pathPrefix() 11 | , _loggedIn = null 12 | , _admin = false 13 | , _importGranted = false 14 | , LoginStore = module.exports = assign({}, EventEmitter.prototype, { 15 | emitChange: function () { this.emit("change"); } 16 | , addChangeListener: function (cb) { this.on("change", cb); } 17 | , removeChangeListener: function (cb) { this.removeListener("change", cb); } 18 | 19 | , isLoggedIn: function () { 20 | return _loggedIn; 21 | } 22 | , isAdmin: function () { 23 | return _admin; 24 | } 25 | , isImportGranted: function () { 26 | return _importGranted; 27 | } 28 | }) 29 | ; 30 | 31 | LoginStore.dispatchToken = AshNazgDispatch.register((action) => { 32 | switch (action.type) { 33 | case "login": 34 | fetch(pp + "api/logged-in", { credentials: "include" }) 35 | .then(utils.jsonHandler) 36 | .then((data) => { 37 | _loggedIn = data.ok; 38 | _admin = data.admin; 39 | // return data; 40 | }) 41 | .then(data => fetch(`${pp}api/scope-granted`, { credentials: "include" })) 42 | .then(data => data.json()) 43 | .then(data => { 44 | if (data && data.scopes) 45 | _importGranted = data.scopes.includes('admin:repo_hook') || data.scopes.includes('write:repo_hook'); 46 | LoginStore.emitChange(); 47 | }) 48 | .catch(utils.catchHandler); 49 | break; 50 | case "logout": 51 | fetch(pp + "api/logout", { credentials: "include" }) 52 | .then(utils.jsonHandler) 53 | .then((data) => { 54 | if (!data.ok) throw "Logout failed"; 55 | _loggedIn = false; 56 | _admin = false; 57 | _importGranted = false; 58 | LoginStore.emitChange(); 59 | }) 60 | .catch(utils.catchHandler); 61 | break; 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /public/img/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /webhook-update.js: -------------------------------------------------------------------------------- 1 | 2 | /* update existing hooks */ 3 | const doAsync = require("doasync"); 4 | var GH = require("./gh"); 5 | var config = require("./config.json"); 6 | var hookURL = config.hookURL; 7 | 8 | 9 | if (require.main === module) { 10 | var Store = require("./store"); 11 | config.logToConsole = false; 12 | const store = new Store(config); 13 | (async () => { 14 | const repos = await doAsync(store).repos(); 15 | for (repo of repos) { 16 | const owner = repo.owner; 17 | const shortname = repo.name; 18 | const { token } = await doAsync(store).getToken(owner); 19 | const gh = new GH({ accessToken: token }); 20 | try { 21 | const { data: hooks } = await gh.octo.request("GET /repos/:owner/:name/hooks", 22 | { 23 | owner: owner, 24 | name: shortname 25 | }); 26 | 27 | const hook = (hooks || hooks.length) ? hooks.find(function(h) { 28 | return h 29 | && h.config 30 | && (h.config.url === hookURL || h.config.url === hookURL.replace('/repo-manager', '/hatchery/repo-manager')); 31 | }) : null; 32 | 33 | if (hook) { 34 | const secret = await doAsync(store).getSecret(`${owner}/${shortname}`); 35 | try { 36 | await gh.octo.request("PATCH /repos/:owner/:name/hooks/:hook", 37 | { 38 | owner: owner, 39 | name: shortname, 40 | hook: hook.id, 41 | data: { 42 | config: { 43 | url: config.hookURL || (config.url + "api/hook"), 44 | content_type: "json", 45 | secret: secret.secret 46 | } 47 | , events: ["pull_request", "issue_comment", "repository"] 48 | } 49 | }); 50 | console.log(`Hook updated for ${owner}/${shortname}`); 51 | } catch (e) { 52 | console.error(`Error updating webhook for ${owner}/${shortname}: ${e.message}`); 53 | } 54 | } else { 55 | console.error(`Hook not found for ${owner}/${shortname}`); 56 | } 57 | } catch (e) { 58 | console.error(`Error fetching webhooks for ${owner}/${shortname}: ${e.message}`); 59 | } 60 | } 61 | })(); 62 | } -------------------------------------------------------------------------------- /css/fonts.css: -------------------------------------------------------------------------------- 1 | 2 | /* Extra-thin, used for headings */ 3 | @font-face { 4 | font-family: "Titillium"; 5 | src: url("../public/fonts/titilliumweb-extralight-webfont.eot"); 6 | src: url("../public/fonts/titilliumweb-extralight-webfont.eot?#iefix") format("embedded-opentype"), 7 | url("../public/fonts/titilliumweb-extralight-webfont.woff2") format("woff2"), 8 | url("../public/fonts/titilliumweb-extralight-webfont.woff") format("woff"), 9 | url("../public/fonts/titilliumweb-extralight-webfont.ttf") format("truetype"); 10 | font-weight: 200; 11 | font-style: normal; 12 | } 13 | 14 | /* Light & light italic, used as regular */ 15 | @font-face { 16 | font-family: "Titillium"; 17 | src: url("../public/fonts/titilliumweb-light-webfont.eot"); 18 | src: url("../public/fonts/titilliumweb-light-webfont.eot?#iefix") format("embedded-opentype"), 19 | url("../public/fonts/titilliumweb-light-webfont.woff2") format("woff2"), 20 | url("../public/fonts/titilliumweb-light-webfont.woff") format("woff"), 21 | url("../public/fonts/titilliumweb-light-webfont.ttf") format("truetype"); 22 | font-weight: normal; 23 | font-style: normal; 24 | } 25 | @font-face { 26 | font-family: "Titillium"; 27 | src: url("../public/fonts/titilliumweb-lightitalic-webfont.eot"); 28 | src: url("../public/fonts/titilliumweb-lightitalic-webfont.eot?#iefix") format("embedded-opentype"), 29 | url("../public/fonts/titilliumweb-lightitalic-webfont.woff2") format("woff2"), 30 | url("../public/fonts/titilliumweb-lightitalic-webfont.woff") format("woff"), 31 | url("../public/fonts/titilliumweb-lightitalic-webfont.ttf") format("truetype"); 32 | font-weight: normal; 33 | font-style: italic; 34 | } 35 | 36 | /* Semibold & semibold italic, used as bold */ 37 | @font-face { 38 | font-family: "Titillium"; 39 | src: url("../public/fonts/titilliumweb-semibold-webfont.eot"); 40 | src: url("../public/fonts/titilliumweb-semibold-webfont.eot?#iefix") format("embedded-opentype"), 41 | url("../public/fonts/titilliumweb-semibold-webfont.woff2") format("woff2"), 42 | url("../public/fonts/titilliumweb-semibold-webfont.woff") format("woff"), 43 | url("../public/fonts/titilliumweb-semibold-webfont.ttf") format("truetype"); 44 | font-weight: bold; 45 | font-style: normal; 46 | } 47 | @font-face { 48 | font-family: "Titillium"; 49 | src: url("../public/fonts/titilliumweb-semibolditalic-webfont.eot"); 50 | src: url("../public/fonts/titilliumweb-semibolditalic-webfont.eot?#iefix") format("embedded-opentype"), 51 | url("../public/fonts/titilliumweb-semibolditalic-webfont.woff2") format("woff2"), 52 | url("../public/fonts/titilliumweb-semibolditalic-webfont.woff") format("woff"), 53 | url("../public/fonts/titilliumweb-semibolditalic-webfont.ttf") format("truetype"); 54 | font-weight: bold; 55 | font-style: italic; 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ash-nazg", 3 | "version": "0.0.4", 4 | "scripts": { 5 | "start": "node server.js", 6 | "watch-server": "nodemon -w server.js -w gh.js -w store.js -w log.js --exec 'npm run start'", 7 | "build-js-debug": "browserify app.jsx --debug | exorcist public/js/app.js.map > public/js/app.js", 8 | "build-js": "NODE_ENV=production browserify app.jsx | uglifyjs - -c warnings=false -m > public/js/app.js", 9 | "XXXX old version watch-js": "nodemon -e jsx,js --watch app.jsx --watch ./components/ --watch ./application/ --watch ./stores/ --watch ./actions/ --exec 'npm run build-js'", 10 | "watch-js": "watchify app.jsx --verbose --ignore-watch=\"**/node_modules/**\" --ignore-watch=\"**/public/**\" -o 'uglifyjs - -c warnings=false -m > public/js/app.js'", 11 | "build-css": "cleancss -o public/css/app.min.css app.css", 12 | "watch-css": "nodemon --ignore ./public/ -e css --exec 'npm run build-css'", 13 | "build": "npm run build-css && NODE_ENV=production npm run build-js", 14 | "watch": "npm run watch-css & npm run watch-js", 15 | "expose": "ngrok http -subdomain=ashnazg 3043", 16 | "test": "npm run build && mocha test/server-spec.js" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/w3c/ash-nazg.git" 21 | }, 22 | "license": "MIT", 23 | "devDependencies": { 24 | "babel-preset-es2015": "6.24.1", 25 | "babel-preset-react": "6.24.1", 26 | "babelify": "7.3.0", 27 | "browserify": "14.4.0", 28 | "clean-css-cli": "^4.3.0", 29 | "exorcist": "0.4.0", 30 | "flux": "3.1.2", 31 | "isomorphic-fetch": "2.2.1", 32 | "mocha": "3.4.2", 33 | "ngrok": "4.0.1", 34 | "nock": "13.2.9", 35 | "nodemon": "1.11.0", 36 | "normalize.css": "7.0.0", 37 | "react": "15.6.1", 38 | "react-dom": "15.6.1", 39 | "react-router": "3.0.5", 40 | "supertest": "6.3.1", 41 | "uglifyify": "3.0.4", 42 | "ungrid": "1.0.1", 43 | "watchify": "3.9.0" 44 | }, 45 | "dependencies": { 46 | "@octokit/core": "^3.1.0", 47 | "@octokit/plugin-paginate-rest": "^2.2.3", 48 | "async": "2.5.0", 49 | "bl": "4.0.3", 50 | "body-parser": "1.17.2", 51 | "cradle": "0.7.1", 52 | "curry": "1.2.0", 53 | "doasync": "^2.0.1", 54 | "es6-object-assign": "1.1.0", 55 | "es6-promise": "4.1.1", 56 | "expect.js": "0.3.1", 57 | "express": "4.15.3", 58 | "express-session": "1.15.3", 59 | "express-winston": "2.4.0", 60 | "node-w3capi": "^2.1.0", 61 | "nodemailer": "6.4.16", 62 | "nodemailer-mock-transport": "1.3.0", 63 | "object-assign": "4.1.1", 64 | "passport": "0.3.2", 65 | "passport-github2": "0.1.10", 66 | "password-generator": "2.1.0", 67 | "proxyquire": "1.8.0", 68 | "react-radio-group": "^3.0.2", 69 | "serve-static": "1.12.3", 70 | "session-file-store": "1.0.0", 71 | "winston": "2.3.1" 72 | }, 73 | "browserify": { 74 | "transform": [ 75 | "babelify" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /application/admin/add-user.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../../components/spinner.jsx"; 4 | import MessageActions from "../../actions/messages"; 5 | import { Link } from "react-router"; 6 | 7 | require("isomorphic-fetch"); 8 | let utils = require("../../application/utils") 9 | , pp = utils.pathPrefix() 10 | ; 11 | 12 | export default class AddUser extends React.Component { 13 | constructor (props) { 14 | super(props); 15 | this.state = { 16 | status: "loading" 17 | , user: null 18 | , username: null 19 | }; 20 | } 21 | componentWillMount () { 22 | this.setState({ username: this.props.params.username }); 23 | } 24 | componentDidMount () { 25 | fetch(pp + "api/user/" + this.state.username, { credentials: "include" }) 26 | .then(utils.jsonHandler) 27 | .then((data) => { 28 | if (data) { 29 | MessageActions.error("User is already in the system, can't add *again*."); 30 | this.setState({ user: data, status: "user-exists" }); 31 | } 32 | else { 33 | this.setState({ status: "ready" }); 34 | } 35 | }) 36 | .catch(utils.catchHandler) 37 | ; 38 | } 39 | 40 | addUser () { 41 | this.setState({ status: "loading" }); 42 | fetch( 43 | pp + "api/user/" + this.state.username + "/add" 44 | , { 45 | method: "post" 46 | , headers: { "Content-Type": "application/json" } 47 | , body: "{}" 48 | , credentials: "include" 49 | } 50 | ) 51 | .then(utils.jsonHandler) 52 | .then((data) => { 53 | MessageActions.success("Successfully added user."); 54 | this.setState({ user: data, status: "user-exists" }); 55 | }) 56 | .catch((e) => { 57 | MessageActions.error("Failure to add user: " + e); 58 | this.setState({ status: "ready" }); 59 | utils.catchHandler(e); 60 | }) 61 | ; 62 | 63 | } 64 | 65 | render () { 66 | let st = this.state 67 | , content 68 | ; 69 | if (st.status === "loading") { 70 | content = ; 71 | } 72 | else if (st.status === "user-exists") { 73 | content =

    74 | User {st.username} is known to the system. 75 | You can edit that account. 76 |

    ; 77 | } 78 | else if (st.status === "ready") { 79 | content =

    ; 80 | } 81 | return
    82 |

    Add user

    83 | {content} 84 |
    85 | ; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /application/admin/users.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../../components/spinner.jsx"; 4 | import UserLine from "./user-line.jsx"; 5 | 6 | require("isomorphic-fetch"); 7 | let utils = require("../utils") 8 | , pp = utils.pathPrefix() 9 | ; 10 | 11 | export default class AdminUsers extends React.Component { 12 | constructor (props) { 13 | super(props); 14 | this.state = { 15 | status: "loading" 16 | , users: null 17 | }; 18 | } 19 | componentDidMount () { 20 | fetch(pp + "api/users", { credentials: "include" }) 21 | .then(utils.jsonHandler) 22 | .then((data) => { 23 | this.setState({ users: data, status: "ready" }); 24 | }) 25 | .catch(utils.catchHandler) 26 | ; 27 | } 28 | 29 | render () { 30 | let st = this.state 31 | , content 32 | ; 33 | if (st.status === "loading") { 34 | content = ; 35 | } 36 | else if (st.status === "ready") { 37 | content = 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | { 50 | st.users.map((u) => { 51 | let email = u.emails.length ? u.emails[0].value : "" 52 | , pic = u.photos.length ? u.photos[0].value : "" 53 | ; 54 | return ; 55 | }) 56 | } 57 |
    PicNameLoginGroupsAffiliationW3C IDActions
    58 | ; 59 | } 60 | return
    61 |

    Users

    62 |

    63 | Use this interface to grant administrative privileges to users and set their 64 | affiliations (both to groups and to members). Be careful, admins are 65 | considered to be reliable people, they can break things. 66 |

    67 |

    68 | The “blanket” status is granted to users who are thereby considered to be 69 | authorised for all pull requests, without needing to be part of a given 70 | group. This is typically restricted to W3C Staff. 71 |

    72 | {content} 73 |
    74 | ; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /application/contributors-list.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../components/spinner.jsx"; 4 | 5 | require("isomorphic-fetch"); 6 | let utils = require("./utils") 7 | , pp = utils.pathPrefix() 8 | ; 9 | 10 | export default class ContributorsList extends React.Component { 11 | constructor (props) { 12 | super(props); 13 | this.state = { 14 | status: "loading" 15 | , owner: null 16 | , shortName: null 17 | , substantiveContributors: [] 18 | }; 19 | } 20 | componentDidMount () { 21 | let owner = this.props.params.owner 22 | , shortName = this.props.params.shortName; 23 | fetch(pp + `api/repos/${owner}/${shortName}/contributors`, { credentials: "include" }) 24 | .then(utils.jsonHandler) 25 | .then((data) => { 26 | this.setState({ 27 | substantiveContributors: data.substantiveContributors 28 | , owner: owner 29 | , shortName: shortName 30 | , status: "ready" 31 | }); 32 | }) 33 | .catch(utils.catchHandler) 34 | ; 35 | } 36 | 37 | render () { 38 | let st = this.state 39 | , content 40 | , repositoryLink = "" 41 | ; 42 | if (st.status === "loading") { 43 | content = ; 44 | } 45 | else if (st.status === "ready") { 46 | repositoryLink = {`${st.owner}/${st.shortName}`}; 47 | content = ( 48 | Object.keys(st.substantiveContributors).length > 0) && 49 | ( 50 |
    51 |

    Substantive contributions

    52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | { 61 | Object.keys(st.substantiveContributors).map((i) => { 62 | return 63 | 64 | 71 | 72 | }) 73 | } 74 | 75 |
    ContributorPR
    {st.substantiveContributors[i].name} 65 |
      66 | {st.substantiveContributors[i].prs.map((pr) => { 67 | return
    • PR #{pr.num} (closed on {pr.lastUpdated} - see IPR check details)
    • 68 | })} 69 |
    70 |
    76 |
    77 | ); 78 | } 79 | 80 | return
    81 |

    List of contributors on {repositoryLink}

    82 | {content} 83 |
    84 | ; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /application/repo-list.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../components/spinner.jsx"; 4 | 5 | require("isomorphic-fetch"); 6 | let utils = require("./utils") 7 | , pp = utils.pathPrefix() 8 | ; 9 | 10 | export default class RepoList extends React.Component { 11 | constructor (props) { 12 | super(props); 13 | this.state = { 14 | status: "loading" 15 | , repos: null 16 | }; 17 | } 18 | componentDidMount () { 19 | fetch(pp + "api/repos", { credentials: "include" }) 20 | .then(utils.jsonHandler) 21 | .then((data) => { 22 | this.setState({ repos: data, status: "ready" }); 23 | }) 24 | .catch(utils.catchHandler) 25 | ; 26 | } 27 | 28 | deleteRepo (repo) { 29 | let st = this.state; 30 | let repos = st.repos; 31 | this.setState({ status: "loading" }); 32 | if (confirm("Are you sure you want to delete this repository?")) { 33 | fetch(pp + `api/repos/${repo}`, { method: "DELETE", credentials: "include" }) 34 | .then(utils.jsonHandler) 35 | .then((data) => { 36 | const newRepos = repos.filter(r => r.fullName !== repo) 37 | this.setState({ repos: newRepos, status: "ready" }); 38 | }) 39 | .catch(utils.catchHandler) 40 | ; 41 | console.log(`Delete repo: ${repo}`); 42 | } 43 | } 44 | 45 | render () { 46 | let st = this.state 47 | , content 48 | ; 49 | if (st.status === "loading") { 50 | content = ; 51 | } 52 | else if (st.status === "ready") { 53 | content = 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | { 62 | st.repos.map((r) => { 63 | let updateAction = ""; 64 | let deleteAction = ""; 65 | if (this.props.isAdmin) { 66 | updateAction = 67 | deleteAction = ; 68 | } 69 | return 70 | 71 | 72 | 73 | {updateAction} 74 | {deleteAction} 75 | 76 | ; 77 | }) 78 | } 79 |
    Full nameGroupsContributors list
    Update
    {r.fullName}{r.groups.map((g) => { return g.name; }).join(", ")}Contributors
    80 | ; 81 | } 82 | return
    83 |

    List of Managed Repositories

    84 | {content} 85 |
    86 | ; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /application/admin/user-line.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import { Link } from "react-router"; 4 | import MessageActions from "../../actions/messages"; 5 | 6 | require("isomorphic-fetch"); 7 | let utils = require("../../application/utils") 8 | , pp = utils.pathPrefix() 9 | ; 10 | 11 | 12 | export default class UserLine extends React.Component { 13 | constructor (props) { 14 | super(props); 15 | this.state = { admin: props.admin, blanket: props.blanket }; 16 | } 17 | makeAdmin () { 18 | this.refs.admin.disabled = true; 19 | fetch( 20 | pp + "api/user/" + this.props.username + "/admin" 21 | , { 22 | method: "put" 23 | , headers: { "Content-Type": "application/json" } 24 | , body: "{}" 25 | , credentials: "include" 26 | } 27 | ) 28 | .then(utils.jsonHandler) 29 | .then((data) => { 30 | if (data.ok) { 31 | MessageActions.success("User turned into admin."); 32 | return this.setState({ admin: true }); 33 | } 34 | this.refs.admin.disabled = false; 35 | MessageActions.error("Failure to set admin flag on user: " + data.error); 36 | }) 37 | .catch(utils.catchHandler) 38 | ; 39 | } 40 | makeBlanket () { 41 | this.refs.blanket.disabled = true; 42 | fetch( 43 | pp + "api/user/" + this.props.username + "/blanket" 44 | , { 45 | method: "put" 46 | , headers: { "Content-Type": "application/json" } 47 | , body: "{}" 48 | , credentials: "include" 49 | } 50 | ) 51 | .then(utils.jsonHandler) 52 | .then((data) => { 53 | if (data.ok) { 54 | MessageActions.success("User given blanket contribution rights."); 55 | return this.setState({ blanket: true }); 56 | } 57 | this.refs.blanket.disabled = false; 58 | MessageActions.error("Failure to set blanket flag on user: " + data.error); 59 | }) 60 | .catch(utils.catchHandler) 61 | ; 62 | } 63 | render () { 64 | let props = this.props 65 | , st = this.state 66 | , makeAdmin = "" 67 | , makeBlanket = "" 68 | , tdStyle = { paddingRight: "20px" } 69 | , name 70 | , pic 71 | ; 72 | if (!st.admin) makeAdmin = ; 73 | if (!st.blanket) makeBlanket = ; 74 | if (props.email) name = {props.displayName}; 75 | else name = props.displayName; 76 | if (props.pic) pic = {props.displayName}; 77 | return 78 | {pic} 79 | {name} 80 | {"@" + props.username} 81 | {props.groups ? Object.keys(props.groups).join(", ") : "none"} 82 | {props.affiliation || "none"} 83 | {props.w3cid || "none"} 84 | 85 | {makeAdmin} 86 | {" "} 87 | {makeBlanket} 88 | {" "} 89 | Edit 90 | 91 | 92 | ; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /application/admin/groups.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../../components/spinner.jsx"; 4 | import GroupLine from "./group-line.jsx"; 5 | 6 | require("isomorphic-fetch"); 7 | let utils = require("../../application/utils") 8 | , pp = utils.pathPrefix() 9 | , groupTypes = { 10 | "community group": "CG" 11 | , "working group": "WG" 12 | , "business group": "BG" 13 | , "coordination group": "CO" 14 | , "interest group": "IG" 15 | } 16 | , ourGroupMap = {} 17 | ; 18 | 19 | export default class AdminGroups extends React.Component { 20 | constructor (props) { 21 | super(props); 22 | this.state = { 23 | status: "loading" 24 | , groups: null 25 | , ourGroups: null 26 | }; 27 | } 28 | componentDidMount () { 29 | var ourGroups; 30 | fetch(pp + "api/groups", { credentials: "include" }) 31 | .then(utils.jsonHandler) 32 | .then((data) => { 33 | ourGroups = data; 34 | ourGroups.forEach((g) => { ourGroupMap[g.w3cid] = true; }); 35 | }) 36 | .then(() => { 37 | return fetch(pp + "api/w3c/groups", { credentials: "include" }) 38 | .then(utils.jsonHandler) 39 | .then((data) => { 40 | data = data.map((g) => { 41 | return { 42 | w3cid: g.id 43 | , name: g.name 44 | , groupType: groupTypes[g.type] || "XX" 45 | , managed: !!ourGroupMap[g.id] 46 | }; 47 | }) 48 | ; 49 | this.setState({ ourGroups: ourGroups, groups: data, status: "ready" }); 50 | }); 51 | }) 52 | .catch(utils.catchHandler) 53 | ; 54 | } 55 | 56 | render () { 57 | let st = this.state 58 | , content 59 | ; 60 | if (st.status === "loading") { 61 | content = ; 62 | } 63 | else if (st.status === "ready") { 64 | content = 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | { 74 | st.groups.map((g) => { 75 | return ; 78 | }) 79 | } 80 |
    TypeNameIDActions
    81 | ; 82 | } 83 | return
    84 |

    Groups

    85 |

    86 | Use this interface to add W3C groups into the system such that they may be 87 | managed for repositories and the such. Please don't add groups unless you 88 | need to as we'd like to keep various UI drop-downs within sane sizes. 89 |

    90 | {content} 91 |
    92 | ; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app.css: -------------------------------------------------------------------------------- 1 | 2 | @import "./node_modules/normalize.css/normalize.css"; 3 | @import "./css/fonts.css"; 4 | @import "./node_modules/ungrid/ungrid.css"; 5 | 6 | html, body { 7 | width: 100%; 8 | height: 100%; 9 | margin: 0; 10 | padding: 0; 11 | font-family: Titillium; 12 | } 13 | header { 14 | border-bottom: 1px solid silver; 15 | padding: 0 0 0 10px; 16 | } 17 | h1, div.app-body, footer { 18 | max-width: 960px; 19 | margin: auto; 20 | } 21 | h1 { 22 | color: #b13737; 23 | font-size: 50px; 24 | font-weight: 300; 25 | padding: 0 0 0 10px; 26 | } 27 | h2 { 28 | font-weight: 300; 29 | font-size: 30px; 30 | margin-bottom: 10px; 31 | } 32 | a[href] { 33 | color: #b13737; 34 | } 35 | a[href]:hover { 36 | color: #003366; 37 | } 38 | 39 | /* navigation */ 40 | .col.nav { 41 | width: 200px; 42 | padding: 0 10px; 43 | } 44 | .nav-box { 45 | margin-bottom: 20px; 46 | } 47 | .nav-box-header { 48 | font-weight: bold; 49 | color: darkgrey; 50 | border-bottom: 1px solid silver; 51 | } 52 | .nav-box ul { 53 | list-style-type: none; 54 | padding: 0; 55 | margin: 0; 56 | } 57 | .nav-box li a, .nav-box li button { 58 | color: #b13737; 59 | display: block; 60 | width: 100%; 61 | text-align: left; 62 | padding: 5px; 63 | text-decoration: none; 64 | cursor: pointer; 65 | background: transparent; 66 | border: none; 67 | font: inherit; 68 | } 69 | .nav-box li a:hover, .nav-box li button:hover { 70 | background: #b13737; 71 | color: #fff; 72 | } 73 | .nav-box li a.active, .nav-box li button:active { 74 | font-weight: bold; 75 | } 76 | .primary-app { 77 | padding-left: 30px; 78 | } 79 | 80 | /* boxes */ 81 | .login-box { 82 | width: 350px; 83 | border: 1px solid silver; 84 | padding: 10px 20px; 85 | } 86 | 87 | /* forms */ 88 | .formline { 89 | width: auto; 90 | padding-bottom: 10px; 91 | } 92 | .formline label { 93 | display: block; 94 | font-weight: bold; 95 | } 96 | .formline label.inline { 97 | display: inline-block; 98 | font-weight: normal; 99 | } 100 | .formline.actions { 101 | text-align: right; 102 | } 103 | 104 | /* ui */ 105 | .spinner { 106 | padding: 20px; 107 | text-align: center; 108 | } 109 | th { 110 | text-align: left; 111 | } 112 | th, td { 113 | padding: 5px; 114 | vertical-align: top; 115 | } 116 | tr { 117 | border-bottom: 1px solid silver; 118 | } 119 | tbody tr:nth-of-type(even) { 120 | background: #eee; 121 | } 122 | td > ul { 123 | margin: 0; 124 | padding-left: 20px; 125 | } 126 | button, a.button { 127 | background: #b13737; 128 | border: none; 129 | border-radius: 5px; 130 | color: #fff; 131 | text-decoration: none; 132 | padding: 0 5px; 133 | cursor: pointer; 134 | } 135 | button:disabled { 136 | background: silver; 137 | color: #333; 138 | } 139 | .flash-list { 140 | margin-left: 230px; 141 | } 142 | .flash-list button { 143 | position: absolute; 144 | top: 10px; 145 | right: 10px; 146 | color: #fff; 147 | } 148 | .flash-list p { 149 | margin: 0; 150 | } 151 | .flash-success, .flash-error { 152 | border-radius: 5px; 153 | padding: 20px; 154 | margin-top: 10px; 155 | position: relative; 156 | } 157 | .flash-success { 158 | border: 1px solid green; 159 | } 160 | .flash-error { 161 | border: 1px solid #df5d5d; 162 | } 163 | .flash-success button { 164 | background: green; 165 | } 166 | .flash-error button { 167 | background: #df5d5d; 168 | } 169 | .good { 170 | color: green; 171 | } 172 | .bad { 173 | color: red; 174 | } 175 | td.good, td.bad { 176 | font-weight: bold; 177 | } 178 | 179 | /* specific areas */ 180 | .users-list .admin { 181 | color: #333; 182 | } 183 | .groups-list .managed { 184 | font-weight: bold; 185 | } 186 | 187 | 188 | -------------------------------------------------------------------------------- /application/pr/open.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../../components/spinner.jsx"; 4 | import { Link } from "react-router"; 5 | 6 | require("isomorphic-fetch"); 7 | let utils = require("../../application/utils") 8 | , pp = utils.pathPrefix() 9 | ; 10 | 11 | function byStatus (pr, status) { 12 | let cs = pr.contribStatus; 13 | return Object.keys(cs).filter((u) => { return cs[u] === status; }); 14 | } 15 | 16 | export default class PROpen extends React.Component { 17 | constructor (props) { 18 | super(props); 19 | this.state = { 20 | status: "loading" 21 | , prs: null 22 | }; 23 | } 24 | componentDidMount () { 25 | fetch(pp + "api/pr/open", { credentials: "include" }) 26 | .then(utils.jsonHandler) 27 | .then((data) => { 28 | this.setState({ prs: data, status: "ready" }); 29 | }) 30 | .catch(utils.catchHandler) 31 | ; 32 | } 33 | 34 | render () { 35 | let st = this.state 36 | , content 37 | ; 38 | if (st.status === "loading") { 39 | content = ; 40 | } 41 | else if (st.status === "ready") { 42 | content = 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | { 52 | st.prs.map( 53 | (pr) => { 54 | return 55 | 58 | 63 | 73 | 85 | 97 | 100 | 101 | ; 102 | } 103 | ) 104 | } 105 |
    Ok?PRGoodUnknownOutsideActions
    56 | {pr.acceptable} 57 | 59 | 60 | {pr.owner + "/" + pr.shortName + "#" + pr.num} 61 | 62 | 64 |
      65 | { 66 | byStatus(pr, "ok") 67 | .map((u) => { 68 | return
    • {u}
    • ; 69 | }) 70 | } 71 |
    72 |
    74 |
      75 | { 76 | byStatus(pr, "undetermined affiliation") 77 | .map((u) => { 78 | return
    • 79 | {u} 80 |
    • ; 81 | }) 82 | } 83 |
    84 |
    86 |
      87 | { 88 | byStatus(pr, "not in group") 89 | .map((u) => { 90 | return
    • 91 | {u} 92 |
    • ; 93 | }) 94 | } 95 |
    96 |
    98 | Details 99 |
    106 | ; 107 | } 108 | return
    109 |

    Open Pull Requests

    110 | {content} 111 |
    112 | ; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /public/css/app.min.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}@font-face{font-family:Titillium;src:url(../fonts/titilliumweb-extralight-webfont.eot);src:url(../fonts/titilliumweb-extralight-webfont.eot?#iefix) format("embedded-opentype"),url(../fonts/titilliumweb-extralight-webfont.woff2) format("woff2"),url(../fonts/titilliumweb-extralight-webfont.woff) format("woff"),url(../fonts/titilliumweb-extralight-webfont.ttf) format("truetype");font-weight:200;font-style:normal}@font-face{font-family:Titillium;src:url(../fonts/titilliumweb-light-webfont.eot);src:url(../fonts/titilliumweb-light-webfont.eot?#iefix) format("embedded-opentype"),url(../fonts/titilliumweb-light-webfont.woff2) format("woff2"),url(../fonts/titilliumweb-light-webfont.woff) format("woff"),url(../fonts/titilliumweb-light-webfont.ttf) format("truetype");font-weight:400;font-style:normal}@font-face{font-family:Titillium;src:url(../fonts/titilliumweb-lightitalic-webfont.eot);src:url(../fonts/titilliumweb-lightitalic-webfont.eot?#iefix) format("embedded-opentype"),url(../fonts/titilliumweb-lightitalic-webfont.woff2) format("woff2"),url(../fonts/titilliumweb-lightitalic-webfont.woff) format("woff"),url(../fonts/titilliumweb-lightitalic-webfont.ttf) format("truetype");font-weight:400;font-style:italic}@font-face{font-family:Titillium;src:url(../fonts/titilliumweb-semibold-webfont.eot);src:url(../fonts/titilliumweb-semibold-webfont.eot?#iefix) format("embedded-opentype"),url(../fonts/titilliumweb-semibold-webfont.woff2) format("woff2"),url(../fonts/titilliumweb-semibold-webfont.woff) format("woff"),url(../fonts/titilliumweb-semibold-webfont.ttf) format("truetype");font-weight:700;font-style:normal}@font-face{font-family:Titillium;src:url(../fonts/titilliumweb-semibolditalic-webfont.eot);src:url(../fonts/titilliumweb-semibolditalic-webfont.eot?#iefix) format("embedded-opentype"),url(../fonts/titilliumweb-semibolditalic-webfont.woff2) format("woff2"),url(../fonts/titilliumweb-semibolditalic-webfont.woff) format("woff"),url(../fonts/titilliumweb-semibolditalic-webfont.ttf) format("truetype");font-weight:700;font-style:italic}@media (min-width:30em){.row{width:100%;display:table;table-layout:fixed}.col{display:table-cell}}body,html{width:100%;height:100%;margin:0;padding:0;font-family:Titillium}header{border-bottom:1px solid silver;padding:0 0 0 10px}div.app-body,footer,h1{max-width:960px;margin:auto}h1{color:#b13737;font-size:50px;font-weight:300;padding:0 0 0 10px}h2{font-weight:300;font-size:30px;margin-bottom:10px}a[href]{color:#b13737}a[href]:hover{color:#036}.col.nav{width:200px;padding:0 10px}.nav-box{margin-bottom:20px}.nav-box-header{font-weight:700;color:#a9a9a9;border-bottom:1px solid silver}.nav-box ul{list-style-type:none;padding:0;margin:0}.nav-box li a,.nav-box li button{color:#b13737;display:block;width:100%;text-align:left;padding:5px;text-decoration:none;cursor:pointer;background:0 0;border:none;font:inherit}.nav-box li a:hover,.nav-box li button:hover{background:#b13737;color:#fff}.nav-box li a.active,.nav-box li button:active{font-weight:700}.primary-app{padding-left:30px}.login-box{width:350px;border:1px solid silver;padding:10px 20px}.formline{width:auto;padding-bottom:10px}.formline label{display:block;font-weight:700}.formline label.inline{display:inline-block;font-weight:400}.formline.actions{text-align:right}.spinner{padding:20px;text-align:center}th{text-align:left}td,th{padding:5px;vertical-align:top}tr{border-bottom:1px solid silver}tbody tr:nth-of-type(even){background:#eee}td>ul{margin:0;padding-left:20px}a.button,button{background:#b13737;border:none;border-radius:5px;color:#fff;text-decoration:none;padding:0 5px;cursor:pointer}button:disabled{background:silver;color:#333}.flash-list{margin-left:230px}.flash-list button{position:absolute;top:10px;right:10px;color:#fff}.flash-list p{margin:0}.flash-error,.flash-success{border-radius:5px;padding:20px;margin-top:10px;position:relative}.flash-success{border:1px solid green}.flash-error{border:1px solid #df5d5d}.flash-success button{background:green}.flash-error button{background:#df5d5d}.good{color:green}.bad{color:red}td.bad,td.good{font-weight:700}.users-list .admin{color:#333}.groups-list .managed{font-weight:700} -------------------------------------------------------------------------------- /app.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Router, Route, Link } from "react-router"; 4 | import {browserHistory } from "react-router"; 5 | import ReactDOM from "react-dom"; 6 | 7 | import Application from "./components/application.jsx"; 8 | import Row from "./components/row.jsx"; 9 | import Col from "./components/col.jsx"; 10 | import NavBox from "./components/nav-box.jsx"; 11 | import NavItem from "./components/nav-item.jsx"; 12 | import Spinner from "./components/spinner.jsx"; 13 | import FlashList from "./components/flash-list.jsx"; 14 | 15 | import UserActions from "./actions/user"; 16 | import LoginStore from "./stores/login"; 17 | 18 | import MessageActions from "./actions/messages"; 19 | import MessageStore from "./stores/message"; 20 | 21 | import Welcome from "./application/welcome.jsx"; 22 | import LoginWelcome from "./application/login.jsx"; 23 | import LogoutButton from "./application/logout-button.jsx"; 24 | import RepoManager from "./application/repo-manager.jsx"; 25 | import RepoList from "./application/repo-list.jsx"; 26 | import ContributorsList from "./application/contributors-list.jsx"; 27 | import AdminUsers from "./application/admin/users.jsx"; 28 | import AdminGroups from "./application/admin/groups.jsx"; 29 | import EditUser from "./application/admin/edit-user.jsx"; 30 | import PickUser from "./application/admin/pick-user.jsx"; 31 | import AddUser from "./application/admin/add-user.jsx"; 32 | import PRViewer from "./application/pr/viewer.jsx"; 33 | import PROpen from "./application/pr/open.jsx"; 34 | import PRLastWeek from "./application/pr/last-week.jsx"; 35 | 36 | let utils = require("./application/utils") 37 | , pp = utils.pathPrefix() 38 | ; 39 | 40 | function getState () { 41 | return { loggedIn: LoginStore.isLoggedIn(), admin: LoginStore.isAdmin(), importGranted: LoginStore.isImportGranted() }; 42 | } 43 | 44 | class AshNazg extends React.Component { 45 | constructor (props) { 46 | super(props); 47 | this.state = getState(); 48 | } 49 | componentDidMount () { 50 | LoginStore.addChangeListener(this._onChange.bind(this)); 51 | UserActions.login(); 52 | } 53 | componentWillUnmount () { 54 | LoginStore.removeChangeListener(this._onChange.bind(this)); 55 | } 56 | _onChange () { 57 | this.setState(getState()); 58 | } 59 | 60 | render () { 61 | let st = this.state 62 | , nav 63 | , body 64 | , admin 65 | , repoNav 66 | , userNav 67 | ; 68 | // show admin links as well 69 | if (st.admin) { 70 | admin = 71 | Users 72 | Add User 73 | Groups 74 | 75 | ; 76 | } 77 | if (st.importGranted) { 78 | repoNav =
    79 | New Repository 80 | Import Repository 81 |
    82 | ; 83 | } 84 | 85 | if (st.loggedIn) { 86 | userNav = 87 | 88 | 89 | ; 90 | } else { 91 | userNav = Login; 92 | } 93 | // when logged in show an actual menu and content 94 | const isRoutePublic = this.props.routes[this.props.routes.length - 1].public; 95 | if (st.loggedIn === true || isRoutePublic) { 96 | nav = 97 | 98 | List Repositories 99 | {repoNav} 100 | 101 | 102 | Currently Open 103 | Active Last Week 104 | 105 | {admin} 106 | {userNav} 107 | ; 108 | body = { renderChildrenWithAdminProp(this.props.children, st.admin) || }; 109 | } 110 | // when logged out off to log in 111 | else if (st.loggedIn === false) { 112 | nav = ; 113 | body = ; 114 | } 115 | // while we don't know if we're logged in or out, spinner 116 | else { 117 | body = ; 118 | } 119 | return 120 | 121 | 122 | {nav} 123 | {body} 124 | 125 | 126 | ; 127 | } 128 | } 129 | 130 | // Set the "isAdmin" property on children components 131 | function renderChildrenWithAdminProp(children, admin) { 132 | return React.Children.map(children, child => 133 | React.cloneElement(child, { 134 | isAdmin: admin 135 | }) 136 | ); 137 | } 138 | 139 | ReactDOM.render( 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | , document.getElementById("ashnazg") 158 | ); 159 | -------------------------------------------------------------------------------- /application/pr/last-week.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../../components/spinner.jsx"; 4 | import { Link } from "react-router"; 5 | // import MessageActions from "../../actions/messages"; 6 | 7 | require("isomorphic-fetch"); 8 | let utils = require("../../application/utils") 9 | , pp = utils.pathPrefix() 10 | ; 11 | 12 | function byStatus (pr, status) { 13 | let cs = pr.contribStatus; 14 | return Object.keys(cs).filter((u) => { return cs[u] === status; }); 15 | } 16 | 17 | export default class PRLastWeek extends React.Component { 18 | constructor (props) { 19 | super(props); 20 | this.state = { 21 | status: "loading" 22 | , prs: null 23 | , affiliations: {} 24 | , affiliationFilter: false 25 | }; 26 | } 27 | componentDidMount () { 28 | var aff = {}; 29 | fetch(pp + "api/pr/last-week", { credentials: "include" }) 30 | .then(utils.jsonHandler) 31 | .then((data) => { 32 | data.forEach((pr) => { 33 | for (var k in (pr.affiliations || {})) { 34 | if (k) aff[k] = pr.affiliations[k]; 35 | } 36 | }); 37 | this.setState({ prs: data, affiliations: aff, status: "ready" }); 38 | }) 39 | .catch(utils.catchHandler) 40 | ; 41 | } 42 | 43 | affFilter () { 44 | this.setState({ affiliationFilter: utils.val(this.refs.affiliation) || false }); 45 | } 46 | 47 | render () { 48 | let st = this.state 49 | , content 50 | , filter 51 | ; 52 | if (st.status === "loading") { 53 | content = ; 54 | } 55 | else if (st.status === "ready") { 56 | filter =
    57 |
    58 | 59 | 68 |
    69 |
    70 | ; 71 | content = 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | { 84 | st.prs.map( 85 | (pr) => { 86 | // filtering 87 | if (st.affiliationFilter && (!pr.affiliations || !pr.affiliations[st.affiliationFilter])) return; 88 | 89 | return 90 | 91 | 94 | 99 | 110 | 120 | 132 | 144 | 147 | 148 | ; 149 | } 150 | ) 151 | } 152 | 153 |
    StatusOk?PRAffiliationsGoodUnknownOutsideActions
    {pr.status} 92 | {pr.acceptable} 93 | 95 | 96 | {pr.owner + "/" + pr.shortName + "#" + pr.num} 97 | 98 | 100 |
      101 | { 102 | Object.keys((pr.affiliations || {})) 103 | .map((aff) => { 104 | if (!aff) return; 105 | return
    • {pr.affiliations[aff]}
    • ; 106 | }) 107 | } 108 |
    109 |
    111 |
      112 | { 113 | byStatus(pr, "ok") 114 | .map((u) => { 115 | return
    • {u}
    • ; 116 | }) 117 | } 118 |
    119 |
    121 |
      122 | { 123 | byStatus(pr, "undetermined affiliation") 124 | .map((u) => { 125 | return
    • 126 | {u} 127 |
    • ; 128 | }) 129 | } 130 |
    131 |
    133 |
      134 | { 135 | byStatus(pr, "not in group") 136 | .map((u) => { 137 | return
    • 138 | {u} 139 |
    • ; 140 | }) 141 | } 142 |
    143 |
    145 | Details 146 |
    154 | ; 155 | } 156 | return
    157 |

    Last Week’s Pull Requests

    158 | {filter} 159 | {content} 160 |
    161 | ; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Repository Manager (Ash-Nazg) 3 | 4 | One interface to find all group contributors and in Intellectual Property Rights (IPR) bind them. 5 | 6 | This tool was created to support contributions made to a group, under the form of pull requests, in 7 | order to assess whether they are IPR-OK or not. It still has some rough edges but hopefully it can 8 | be usable enough to get started, and perfected over time. 9 | 10 | The tool is at currently in [my labs hatchery](https://labs.w3.org/hatchery/repo-manager/), but 11 | hopefully at some point some kind soul will place it at a more memorable URL. 12 | 13 | When you get there, you will be asked to log in through GitHub. You can't do much without that, 14 | because most of the actions you can undertake through the tool (or that the tool can undertake on 15 | your behalf when reacting to a GitHub event) require authorised access to GitHub. The permissions 16 | it requires are rather broad; that is because it is difficult to be granular with GitHub 17 | permissions. The tool isn't doing anything unholy. 18 | 19 | Once you log in your user will be created; if you need to be an admin just ask someone to give you 20 | that flag from the "Edit User" page. *Note:* there are currently two distinct login flows. Some features 21 | such as create and import repositories do not appear unless you sign in via the second link. 22 | 23 | If you need to deploy or to hack on this tool, you will want to read the 24 | [Development Guide](https://github.com/w3c/ash-nazg/blob/master/DEVELOPMENT.md) 25 | 26 | ## Common Tool 27 | 28 | ### [New Repository](https://labs.w3.org/repo-manager/repo/new) 29 | 30 | This is basically what people should use when they want to start a new specification with the WG/CG. 31 | It gives you a choice of the organizations under which you are allowed to create a new repo 32 | (including your own user), and you can pick the name of the repo and the groups to which it 33 | belongs. 34 | 35 | *Note*: the list of organizations depends on the user's GitHub organizations. If you are owner of an 36 | organization and you don't see it in the list, you need to grant the repository manager access to that 37 | organization. To do so, go in your 38 | ['Authorized OAuth Apps' settings`](https://github.com/settings/applications), click on 'W3C Repository 39 | Manager' and grant access to the new organization. 40 | 41 | Hitting "Create" can take a little while as the tool does all of the following, live: 42 | 43 | * Creates the repo on GitHub 44 | * Adds several files, notably the `LICENSE.md` and `CONTRIBUTE.md`, a `w3c.json` file which can be 45 | used by other tools, and an `index.html` that's a bare-bones ReSpec spec ready to be edited. 46 | * Adds a hook to the repo such that pull requests and comments on them are sent to us, including one 47 | distinct cryptographic secret per repo. 48 | * Saves all the relevant info on our side. 49 | 50 | Most users should only ever have to use that. Once done they can go and play in their repo. 51 | 52 | **Important**: [`w3cbot`](https://github.com/w3cbot) should be able to comment on the different pull 53 | requests so you should consider adding @w3cbot as a member of the organization. 54 | 55 | ### [Import Repository](https://labs.w3.org/repo-manager/repo/import) 56 | 57 | This is the same as "New" but for an existing repo. It will ***never*** overwrite something there so 58 | it is the user's responsibility to check that the repo is okay once imported. 59 | 60 | ### Logout 61 | 62 | This should be obvious. If it isn't, please don't use the application. 63 | 64 | ### How Pull Requests Get Handled 65 | 66 | Whenever a pull request is made against a repo that is under the tool's management, we get notified. 67 | We use this information to assess if the PR is acceptable (i.e. has all its contributors in at least 68 | one of the groups that the repo belongs to). 69 | 70 | Count as contributors not just the person making the pull request, but also anyone added either in 71 | the PR description or in any subsequent comment using "`+@username`" on a line on its own. If a 72 | contributor was added by mistake, she can be removed with "`-@username`" on a line on its own. This 73 | includes the person making the PR. Thanks to that, you can issue a PR completely on behalf of 74 | someone else. 75 | 76 | Every time a PR is created or has a comment with a username change, the status of the PR is changed. 77 | If it's acceptable it'll get changed to green with a note indicating that it's fine; if not it gets 78 | changed to some ugly brown with a red cross (and a link that people can use to check the issue in 79 | more detail). 80 | 81 | 82 | ## Admin Tools 83 | 84 | ### Currently Open Pull Requests 85 | 86 | This list all PRs that are now open, even old ones. It lists useful details such as which users are 87 | being problematic either because they are unknown (not in the system at all) or outside (known to 88 | the system but not in one of the right groups for that repo). 89 | 90 | You can go to PR details by clicking "Details". 91 | 92 | ### PR Details 93 | 94 | If the PR is not in an acceptable state, this will list problematic users with a link to fix them 95 | each. The fix can either be "Add to system" or "Edit" (details below). 96 | 97 | The idea is that the vast majority of non-acceptable PRs in the first few weeks will come from 98 | people who are simply not known, but that relatively quickly it ought to become a less frequent 99 | occurrence. 100 | 101 | If it so happens that all of the problematic users can be added to the system or to the right group, 102 | and that you have done so, then you can return to the PR details page and hit "Revalidate". We could 103 | revalidate every time a user is added or edited, but it's pretty costly so for the time being it is 104 | done this way. Revalidation will of course update both the local state and the PR's mergability 105 | indicator on GitHub. 106 | 107 | ### Add User to system 108 | 109 | For users that are unknown to the system, they can be added by following on of those links and just 110 | clicking that button. This is always an innocuous operation; it does not give the user any special 111 | rights nor can it make a PR OK (since the user needs to be in a group for that). 112 | 113 | ### Active Last Week PRs 114 | 115 | This is a list of pull requests, in any state, that saw activity last week. They can be filtered 116 | according to the affiliation of the companies that made the contributions. This is essentially so 117 | that AC reps who have people in CGs who are only supposed to contribute to some specific work but 118 | not all of it can monitor what's been going on and avail themselves of their 45 days retraction 119 | window. Similar affordances are available as for the list of open PRs. 120 | 121 | ### Edit User 122 | 123 | The interface to edit users is where the W3C data model and the GitHub data model get to meet. This 124 | alone is scary; I've tried to make it less scary. 125 | 126 | A list of the groups known to the system is shown, the user can be added and removed from them 127 | there. If the user's affiliation is unset, once some groups have been added you can click "Set". 128 | This will load up a list that is the *intersection* of membership in the selected groups. The UI 129 | will also try to select the user with the name matching their GitHub profile (which may not always 130 | work, but often does). Hitting "OK" will associate the GH user with the W3C user, making it possible 131 | for us to use affiliation information. Don't forget to hit "Save". 132 | 133 | This is a little convoluted but it's the best I could do with the current APIs from both GitHub and 134 | the W3C backend. Hopefully it can be simplified in the future. 135 | 136 | ### Admin > Users 137 | 138 | This is a list of users. Things you can do there include making them admins and giving them blanket 139 | contribution rights. **USE EITHER WITH CARE**. 140 | 141 | Admins should normally not be able to break the system, but they can enter all sorts of bogus 142 | information that would be really annoying. Only grant admin when you're sure; it's probably better 143 | to ask others first. 144 | 145 | Blanket is a different type of superpower: users with blanket access are considered acceptable 146 | contributors to ALL repos, irrespective of their group memberships. This should normally be 147 | restricted to W3C team people. 148 | 149 | ### Admin > Groups 150 | 151 | This is a list of all W3C groups. You will note that most have an "Add" button next to them: those 152 | are the ones that are in W3C but not in this system. Please do *not* start adding groups unless they 153 | explicitly want to be managed under this system. We only want people to create/import repos for 154 | groups that are actually using this system. Clicking "Add" makes that group one of those available 155 | for repos and users to belong to, adding too many will make those dialogs unwieldy. 156 | 157 | Share & Enjoy! 158 | -------------------------------------------------------------------------------- /pr-check.js: -------------------------------------------------------------------------------- 1 | const async = require("async") 2 | , notification = require('./notification') 3 | , w3ciprcheck = require('./w3c-ipr') 4 | , doAsync = require('doasync') // rather than utils.promisy to get "free" support for object methods 5 | , w3c = require("node-w3capi") 6 | , types = { 7 | 'working group': 'wg', 8 | 'interest group': 'ig', 9 | 'community group': 'cg', 10 | 'business group': 'bg' 11 | }; 12 | 13 | let store, log; 14 | 15 | async function findW3CUserFromGithub(user) { 16 | log.info("Looking for github user with id " + user.ghID + " in W3C API"); 17 | try { 18 | let w3cuser = await w3c.user({type: 'github', id: user.ghID}).fetch(); 19 | log.info(JSON.stringify(w3cuser, null, 2)); 20 | await doAsync(store).mergeOnUser(user.username, { 21 | w3cid: w3cuser.id, 22 | w3capi: w3cuser._links.self.href.replace(/.*\//, "") 23 | }); 24 | } catch (err) { 25 | return user; 26 | } 27 | log.info("Found matching W3C user"); 28 | return doAsync(store).getUser(user.username); 29 | } 30 | 31 | async function findOrCreateUserFromGithub(username, gh) { 32 | let user; 33 | try { 34 | user = await doAsync(store).getUser(username); 35 | } catch (err) { 36 | if (err.error !== "not_found") throw err; 37 | } 38 | if (!user) { 39 | log.info("Getting GH id from github for " + username); 40 | let ghuser = await gh.getUser(username); 41 | // we store this for sake of efficiency 42 | await doAsync(store).addUser(ghuser); 43 | return findW3CUserFromGithub(ghuser); 44 | } else { 45 | // Let's check if the link has since been established 46 | if (!user.w3capi) { 47 | return findW3CUserFromGithub(user); 48 | } else { 49 | return user; 50 | } 51 | } 52 | } 53 | 54 | 55 | async function getStoredPR(fullname) { 56 | log.info("Setting status for PR " + fullname); 57 | let repo = await doAsync(store).getRepo(fullname); 58 | if (!repo) throw ("Unknown repository: " + fullname); 59 | let token = await doAsync(store).getToken(repo.owner); 60 | if (!token) throw ("Token not found for: " + repo.owner); 61 | return {repoGroups: repo.groups, token}; 62 | } 63 | 64 | async function updateStoredPR(pr) { 65 | log.info("Setting status for PR " + pr.fullName); 66 | await doAsync(store).updatePR(pr.fullName, pr.num, pr); 67 | return pr; 68 | } 69 | 70 | async function setGhStatus(gh, status) { 71 | return new Promise((res, rej) => { 72 | gh.status(status, (err) => { 73 | if (err) log.error(err); 74 | res(); 75 | }) 76 | }); 77 | } 78 | 79 | async function checkPrScope(gh, pr) { 80 | const ignoreFiles = ["package.json", "package-lock.json", ".travis.yml", "w3c.json", "CONTRIBUTING.md", "LICENSE.md", "LICENSE.txt", "CODE_OF_CONDUCT.md"]; 81 | const ignorePath = ".github/"; 82 | let files; 83 | try { 84 | files = await gh.getPrFiles(pr.owner, pr.shortName, pr.num); 85 | } catch(err) { 86 | log.error(err); 87 | // if unsure, assumes it is IPR-relevant 88 | return true; 89 | } 90 | return !(files.map(f => f.filename).every(p => ignoreFiles.includes(p) || p.startsWith(ignorePath))); 91 | } 92 | 93 | function prChecker(config, argLog, argStore, GH, mailer) { 94 | log = argLog; 95 | store = argStore; 96 | return { 97 | validate: async function prStatus (pr, delta, cb) { 98 | const currentPrAcceptability = pr.acceptable; 99 | const prString = pr.owner + "/" + pr.shortName + "/" + pr.num; 100 | const statusData = { 101 | owner: pr.owner, 102 | shortName: pr.shortName, 103 | sha: pr.sha, 104 | payload: { 105 | state: "pending", 106 | target_url: config.url + "pr/id/" + prString, 107 | description: "PR is being assessed, results will come shortly.", 108 | context: "ipr" 109 | } 110 | }; 111 | let token, repoGroups, iprRelevant = true; 112 | try { 113 | ({token, repoGroups} = await getStoredPR(pr.fullName)); 114 | } catch (err) { 115 | return cb(err); 116 | } 117 | const gh = new GH({ accessToken: token.token }); 118 | log.info("Setting pending status on " + prString); 119 | await setGhStatus(gh, statusData); 120 | 121 | iprRelevant = await checkPrScope(gh, pr, log); 122 | if (!iprRelevant) { 123 | statusData.payload.state = "success"; 124 | statusData.payload.description = "PR files identified as non-substantive."; 125 | log.info("Setting status success for " + prString); 126 | pr.acceptable = "yes"; 127 | await setGhStatus(gh, statusData); 128 | try { 129 | let updatedPR = await updateStoredPR(pr); 130 | return cb(null, updatedPR); 131 | } catch (err) { 132 | return cb(err); 133 | } 134 | } 135 | 136 | if (pr.markedAsNonSubstantiveBy) { 137 | pr.acceptable = "yes"; 138 | statusData.payload.state = "success"; 139 | statusData.payload.description = "PR deemed acceptable as non-substantive by @" + pr.markedAsNonSubstantiveBy + "."; 140 | log.info("Setting status success for " + prString); 141 | await setGhStatus(gh, statusData); 142 | try { 143 | let updatedPR = await updateStoredPR(pr); 144 | return cb(null, updatedPR); 145 | } catch (err) { 146 | return cb(err); 147 | } 148 | } 149 | 150 | log.info("Looking up users for " + prString); 151 | let contrib = {}; 152 | log.info("Finding deltas for " + prString); 153 | pr.contributors.forEach(function (c) { contrib[c] = true; }); 154 | delta.add.forEach(function (c) { contrib[c] = true; }); 155 | delta.remove.forEach(function (c) { delete contrib[c]; }); 156 | pr.contributors = Object.keys(contrib); 157 | pr.contribStatus = {}; 158 | pr.groups = repoGroups; 159 | pr.affiliations = {}; 160 | let results = await Promise.all( 161 | pr.contributors.map(async function(username) { 162 | let user = await findOrCreateUserFromGithub(username, gh); 163 | // TODO: check that this is appropriate 164 | // and if so, replace by check of affiliation 165 | // to staff 166 | if (user.blanket) { 167 | pr.affiliations[user.affiliation] = user.affiliationName; 168 | pr.contribStatus[username] = "ok"; 169 | return "ok"; 170 | } 171 | // if user not found in W3C API, 172 | // report undetermined affiliation 173 | // TODO: We will contact contributor to ask 174 | // establishing the connection. 175 | if (!user.w3capi) { 176 | pr.contribStatus[username] = "undetermined affiliation"; 177 | return "undetermined affiliation"; 178 | } 179 | 180 | let groups = []; 181 | 182 | for (let g of repoGroups) { 183 | // get group type and shortname 184 | try { 185 | const group = await w3c.group(g).fetch(); 186 | groups.push({id: g, type: types[group.type], shortname: group.shortname}); 187 | } catch (err) { 188 | return cb(err); 189 | } 190 | } 191 | 192 | let result = await w3ciprcheck(w3c, user.w3capi, user.displayName, groups, store); 193 | let ok = result.ok; 194 | if (ok) { 195 | pr.affiliations[result.affiliation.id] = result.affiliation.name; 196 | pr.contribStatus[username] = "ok"; 197 | return "ok"; 198 | } else { 199 | // we assume that all groups are of the same type 200 | let group = await doAsync(store).getGroup(repoGroups[0]); 201 | if (!group) throw "Unknown group: " + repoGroups[0]; 202 | if (group.groupType === 'WG') { 203 | log.info("Looking up for non-participant licensing contribution"); 204 | if (pr.repoId) { 205 | let nplc; 206 | try { 207 | nplc = await w3c.nplc({repoId: pr.repoId, pr: pr.num}).fetch(); 208 | } catch (err) { 209 | // Non-participant licensing contribution doesn't exist 210 | pr.contribStatus[username] = "not in group"; 211 | return "not in group"; 212 | } 213 | const u = nplc.commitments.find(c => c.user["connected-accounts"].find(ca => ca.nickname === username)); 214 | const contribStatus = (u.commitment_date === undefined) ? "commitment pending" : "ok"; 215 | pr.contribStatus[username] = contribStatus; 216 | return contribStatus; 217 | } else { 218 | pr.contribStatus[username] = "no commitment made - missing repository ID"; 219 | return "no commitment made - missing repository ID"; 220 | } 221 | } else { 222 | pr.contribStatus[username] = "not in group"; 223 | return "not in group"; 224 | } 225 | } 226 | })); 227 | let good = results.every(st => st === "ok"); 228 | log.info("Got users for " + prString + " results good? " + good); 229 | if (good) { 230 | pr.acceptable = "yes"; 231 | pr.unknownUsers = []; 232 | pr.outsideUsers = []; 233 | statusData.payload.state = "success"; 234 | statusData.payload.description = "PR deemed acceptable."; 235 | log.info("Setting status success for " + prString); 236 | await setGhStatus(gh, statusData); 237 | let updatedPR = await updateStoredPR(pr); 238 | return cb(null, updatedPR); 239 | } 240 | pr.acceptable = "no"; 241 | pr.unknownUsers = []; 242 | pr.outsideUsers = []; 243 | pr.unaffiliatedUsers = []; 244 | for (var u in pr.contribStatus) { 245 | if (pr.contribStatus[u] === "unknown") pr.unknownUsers.push(u); 246 | if (pr.contribStatus[u] === "undetermined affiliation") pr.unaffiliatedUsers.push(u); 247 | if (pr.contribStatus[u] === "not in group") pr.outsideUsers.push(u); 248 | } 249 | var msg = "PR has contribution issues."; 250 | const collateUserNames = users => users.map(u => "@" + u).join (", "); 251 | if (pr.unknownUsers.length) 252 | msg += " The following users were unknown: " + collateUserNames(pr.unknownUsers) + 253 | "."; 254 | if (pr.unaffiliatedUsers.length) 255 | msg += " The following users' affiliation could not be determined: " + collateUserNames(pr.unaffiliatedUsers) + "."; 256 | if (pr.outsideUsers.length) 257 | msg += " The following users were not in the repository's groups: " + collateUserNames(pr.outsideUsers) + "."; 258 | statusData.payload.state = "failure"; 259 | statusData.payload.description = msg; 260 | if (statusData.payload.description.length > 140) { 261 | statusData.payload.description = statusData.payload.description.slice(0, 139) + '…'; 262 | } 263 | log.info("Setting status failure for " + prString + ", " + msg); 264 | await setGhStatus(gh, statusData); 265 | let updatedPR = await updateStoredPR(pr); 266 | // Only send email notifications 267 | // if the status of the PR has just 268 | // changed 269 | if (currentPrAcceptability !== pr.acceptable) { 270 | // FIXME: make it less context-dependent 271 | await notification.notifyContacts(gh, pr, statusData, mailer, {from: config.notifyFrom, fallback: config.emailFallback || [], cc: config.emailCC || []}, store, log); 272 | return cb(null, updatedPR); 273 | } 274 | return cb(null, updatedPR); 275 | } 276 | }; 277 | } 278 | 279 | module.exports = prChecker; 280 | -------------------------------------------------------------------------------- /application/pr/viewer.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../../components/spinner.jsx"; 4 | import { Link } from "react-router"; 5 | import MessageActions from "../../actions/messages"; 6 | 7 | require("isomorphic-fetch"); 8 | let utils = require("../../application/utils") 9 | , pp = utils.pathPrefix() 10 | ; 11 | 12 | export default class PRViewer extends React.Component { 13 | constructor (props) { 14 | super(props); 15 | this.state = { 16 | status: "loading" 17 | , pr: null 18 | , owner: null 19 | , shortName: null 20 | , num: null 21 | , groupDetails: [] 22 | , isTeamcontact: null 23 | }; 24 | } 25 | componentDidMount () { 26 | let owner = this.props.params.owner 27 | , shortName = this.props.params.shortName 28 | , num = this.props.params.num 29 | , groupDetails 30 | , isTeamcontact = false 31 | ; 32 | this.setState({ owner: owner, shortName: shortName, num: num }); 33 | fetch(pp + "api/pr/" + [owner, shortName, num].join("/"), { credentials: "include" }) 34 | .then(utils.jsonHandler) 35 | .then((data) => { 36 | groupDetails = data.groupDetails || []; 37 | this.setState({ pr: data }); 38 | }) 39 | .then(() => { 40 | const types = { 41 | 'working group': 'wg', 42 | 'interest group': 'ig', 43 | 'community group': 'cg', 44 | 'business group': 'bg' 45 | }; 46 | return Promise.all(groupDetails 47 | .map(g => fetch(pp + "api/w3c/group/" + g.w3cid) 48 | .then(utils.jsonHandler) 49 | .then(groupdata => { 50 | const group = groupDetails.find(gg => gg.w3cid === g.w3cid); 51 | group.joinhref = groupdata._links.join.href; 52 | group.shortname = groupdata.shortname; 53 | group.type = types[groupdata.type]; 54 | }) 55 | )); 56 | }) 57 | .then(() => fetch(pp + "api/team-contact-of", { credentials: "include"}) 58 | .then(utils.jsonHandler) 59 | .then((data) => { 60 | if (!data.hasOwnProperty('error')) { 61 | isTeamcontact = data.some(wg => groupDetails.map(g => `https://api.w3.org/groups/${g.type}/${g.shortname}`).includes(wg.href)); 62 | } 63 | }) 64 | ) 65 | .then(() => this.setState({groupDetails, status: "ready", isTeamcontact: isTeamcontact})) 66 | .catch(utils.catchHandler) 67 | ; 68 | 69 | } 70 | 71 | revalidate () { 72 | let st = this.state; 73 | this.setState({ status: "loading" }); 74 | fetch(pp + "api/pr/" + [st.owner, st.shortName, st.num, "revalidate"].join("/"), { method: "POST", credentials: "include" }) 75 | .then(utils.jsonHandler) 76 | .then((data) => { 77 | console.log("got data", data); 78 | this.setState({ pr: data, status: "ready" }); 79 | if (data.error) return MessageActions.error(data.error); 80 | }) 81 | .catch(utils.catchHandler) 82 | ; 83 | } 84 | 85 | markSubstantiveOrNot (ev) { 86 | const nonsubstantive = ev.target.name === "nonsubstantive"; 87 | let st = this.state; 88 | this.setState({ status: "loading" }); 89 | fetch(pp + "api/pr/" + [st.owner, st.shortName, st.num, "markAs" + (nonsubstantive ? "Non" : "") + "Substantive"].join("/"), { method: "POST", credentials: "include" }) 90 | .then(utils.jsonHandler) 91 | .then((data) => { 92 | this.setState({ pr: data, status: "ready" }); 93 | if (data.error) return MessageActions.error(data.error); 94 | }) 95 | .catch(utils.catchHandler) 96 | ; 97 | 98 | } 99 | 100 | render () { 101 | let st = this.state 102 | , content 103 | , link 104 | , doc 105 | ; 106 | if (st.status === "loading") { 107 | content = ; 108 | link = "loading"; 109 | } 110 | else if (st.status === "ready") { 111 | let cs = st.pr.contribStatus || {} 112 | , thStyle = { paddingRight: "20px" } 113 | ; 114 | link = 115 | {st.owner + "/" + st.shortName + "#" + st.num} 116 | 117 | ; 118 | let action; 119 | const prAcceptance = st.pr.acceptable === "yes" ? (st.pr.markedAsNonSubstantiveBy ? "Marked as non substantive by " + st.pr.markedAsNonSubstantiveBy : "Made with proper IPR commitments" ) : "no"; 120 | if (st.pr.acceptable === "yes") { 121 | let revert = ""; 122 | if (st.pr.markedAsNonSubstantiveBy) { 123 | revert = ; 124 | } 125 | let merge = ""; 126 | if (st.pr.status === "open") { 127 | merge = go merge it at {link}; 128 | } 129 | action = {revert}{ revert && merge ? " — or " : (merge ? " — " : "")}{merge}; 130 | } else { 131 | let nplc; 132 | if ((this.props.isAdmin || st.isTeamcontact) && st.pr.repoId && !Object.keys(cs).some(u => cs[u] === "undetermined affiliation")) { 133 | let st = this.state 134 | , nplcUrl = new URL(['/standards/licensing/contributions', st.pr.repoId, st.num, 'edit/'].join("/"), 'https://www.w3.org/') 135 | , qs = st.pr.contributors.map(c => 'contributors[]=' + c).concat(st.pr.groups.map(g => 'groups[]=' + g)).join('&') 136 | ; 137 | nplcUrl.search = qs; 138 | nplc = Ask for non-participant commitment 139 | } 140 | action = {nplc}; 141 | } 142 | content = 143 | 144 | 145 | 150 | 151 | 152 | 153 | 183 | 184 |
    Acceptable 146 | {prAcceptance} 147 | {" "} 148 | {action} 149 |
    Contributors 154 |
      155 | { 156 | Object.keys(cs) 157 | .map((username) => { 158 | if (cs[username] === "ok") { 159 | return
    • {username}
    • ; 160 | } 161 | else if (cs[username] === "undetermined affiliation") { 162 | return
    • 163 | {username} is unknown,{" "} they need to link their GitHub account with a W3C account. 164 |
    • 165 | ; 166 | } 167 | else if (cs[username] === "commitment pending") { 168 | return
    • 169 | {username} needs to submit their non-participant licensing commitment via the link they received by email. 170 |
    • 171 | ; 172 | } 173 | else { 174 | const groupJoins = (st.groupDetails || []).map((g, i, a) => join the {g.name} {i < a.length - 1 ? " or " : ""}); 175 | return
    • 176 | {username} did not make IPR commitments for this group. To make the IPR commitments, {username} should {groupJoins}. 177 |
    • ; 178 | } 179 | }) 180 | } 181 |
    182 |
    185 | ; 186 | if (st.pr.acceptable == "no" && st.pr.unaffiliatedUsers.length) { 187 | var groupDoc, groups = utils.andify(st.groupDetails.map(g => g.name), "or"); 188 | // we assume that all groups are of the same type 189 | if (!st.groupDetails || !st.groupDetails.length || st.groupDetails[0].groupType === 'WG') { 190 | let instructions = instructions 191 | groupDoc = [
  • if the said contributor works for a W3C Member organization participating to {groups}, they should get a W3C account. Once done or if they already have one, they should then link their W3C and github accounts together.
  • , 192 |
  • Otherwise, the WG’s team contacts will request the contributors to sign the non-participant licensing commitments{!st.pr.repoId ? " (missing repository ID in the database)" : ""}{(this.props.isAdmin || st.isTeamcontact) ? [" - ", instructions] : ""}
  • ] 193 | } else { 194 | groupDoc =
  • Otherwise, the group’s chairs will need to figure how to get the proper IPR commitment from the contributor.
  • 195 | } 196 | 197 | doc =
    198 |

    Some of the contributors in this pull request were not recognized as having made the required IPR commitment to make substantive changes to the specification in this repository.

    199 |

    To fix this situation, please see which of the following applies:

    200 |
      201 |
    • if the contribution does not concern a normative part of a specification, or is editorial in nature (e.g. fixing typos or examples), the contribution can be marked as non-substantive with the button above - this requires to be logged-in in this system.
    • 202 |
    • if the said contributor is a member of {groups}, they should link their W3C and github accounts together.
    • 203 | {groupDoc} 204 |
    205 |
    ; 206 | } 207 | } 208 | return
    209 |

    Pull Request {link}

    210 | {content} 211 | {doc} 212 |
    213 | ; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /application/admin/edit-user.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../../components/spinner.jsx"; 4 | import MessageActions from "../../actions/messages"; 5 | 6 | let async = require("async"); 7 | require("isomorphic-fetch"); 8 | let utils = require("../../application/utils") 9 | , pp = utils.pathPrefix() 10 | ; 11 | 12 | export default class EditUser extends React.Component { 13 | constructor (props) { 14 | super(props); 15 | this.state = { 16 | status: "loading" 17 | , user: null 18 | , groups: null 19 | , username: null 20 | , modified: false 21 | , w3cidStatus: "showing" 22 | , userList: null 23 | , userSuggest: null 24 | }; 25 | } 26 | componentWillMount () { 27 | this.setState({ username: this.props.params.username }); 28 | } 29 | componentDidMount () { 30 | let user; 31 | fetch(pp + "api/user/" + this.state.username, { credentials: "include" }) 32 | .then(utils.jsonHandler) 33 | .then((data) => { 34 | user = data; 35 | return fetch(pp + "api/groups", { credentials: "include" }) 36 | .then(utils.jsonHandler) 37 | .then((data) => { 38 | this.setState({ user: user, groups: data, status: "ready" }); 39 | }) 40 | ; 41 | }) 42 | .catch(utils.catchHandler) 43 | ; 44 | } 45 | 46 | removeGroup (w3cid) { 47 | let user = this.state.user; 48 | delete user.groups[w3cid]; 49 | this.setState({ user: user, modified: true, w3cidStatus: "showing" }); 50 | } 51 | 52 | addGroup (w3cid) { 53 | let user = this.state.user; 54 | if (!user.groups) user.groups = {}; 55 | user.groups[w3cid + ""] = true; 56 | this.setState({ user: user, modified: true, w3cidStatus: "showing" }); 57 | } 58 | 59 | pickW3CID () { 60 | this.setState(({ w3cidStatus: "loading" })); 61 | let groups = Object.keys(this.state.user.groups); 62 | async.map( 63 | groups 64 | , (group, cb) => { 65 | fetch(pp + "api/w3c/group/" + group + "/users", { credentials: "include" }) 66 | .then(utils.jsonHandler) 67 | .then((data) => { 68 | // sometimes you get a 404, just handle it 69 | if (!data.length) { 70 | console.error("Got a 404 for " + group + ", skipping."); 71 | return; 72 | } 73 | cb(null, data); 74 | }) 75 | .catch(utils.catchHandler) 76 | ; 77 | } 78 | , (err, data) => { 79 | // sometimes you get an empty group, just handle it 80 | if (!data || !data.length) { 81 | console.error("Got no participants for " + group + ", skipping."); 82 | return; 83 | } 84 | let users = {}, hrefs = {}; 85 | data.forEach((res) => { 86 | // sometimes you get an empty group, just handle it 87 | if (!res || !res.length) { 88 | console.error("Got no participants for " + group + ", skipping."); 89 | return; 90 | } 91 | res.forEach((u) => { 92 | if (!u) return; 93 | if (!users[u.href]) users[u.href] = 0; 94 | users[u.href]++; 95 | hrefs[u.href] = u.title; 96 | }); 97 | }); 98 | // we're looking for users who are in all listed groups, this is an intersection 99 | // if you've picked the wrong groups, this could easily be empty 100 | let profiles = Object.keys(users) 101 | .filter((href) => { return users[href] === data.length; }) 102 | .sort((a, b) => { return hrefs[a].localeCompare(hrefs[b]); }) 103 | .map((h) => { return { displayName: hrefs[h], href: h, id: h.replace(/.*\//, "") }; }) 104 | , curName = this.state.user.displayName 105 | , suggest 106 | ; 107 | profiles.forEach((u) => { 108 | if (u.displayName === curName) suggest = u.id; 109 | }); 110 | this.setState({ w3cidStatus: "suggesting", userList: profiles, userSuggest: suggest }); 111 | } 112 | ); 113 | } 114 | 115 | setUser () { 116 | this.setState(({ w3cidStatus: "setting-user", modified: true })); 117 | let user = this.state.user 118 | , apiID = utils.val(this.refs.w3cUser) 119 | , groups = Object.keys(user.groups) 120 | , self = this 121 | ; 122 | fetch(pp + "api/w3c/user/" + apiID, { credentials: "include" }) 123 | .then(utils.jsonHandler) 124 | .then((data) => { 125 | user.w3cid = data.id + ""; 126 | user.w3capi = apiID; 127 | return fetch(pp + "api/w3c/user/" + apiID + "/affiliations", { credentials: "include" }) 128 | .then(utils.jsonHandler) 129 | .then((data) => { 130 | // KLUDGE Alert 131 | // There should be one affiliation / group 132 | // See https://github.com/w3c/ash-nazg/issues/29 133 | async.filter(groups, 134 | function(group, cb) { 135 | fetch(pp + "api/w3c/group/" + group, { credentials: "include" }) 136 | .then(utils.jsonHandler) 137 | .then((data) => { 138 | // Warning: async 2 has a different API 139 | cb(data.type === "community group"); 140 | }) 141 | .catch(utils.catchHandler) 142 | ; 143 | }, function (err, results) { 144 | if (err) return utils.catchHandler(err); 145 | var aff; 146 | if (results.length > 0) { 147 | // If we're dealing with (at least one) CG 148 | // we can't accept Invited Expert as an affiliation 149 | aff = data.filter((it) => { 150 | return !/invited expert/i.test(it.title); 151 | })[0]; 152 | } else { 153 | aff = data[0]; 154 | } 155 | user.affiliation = aff.href.replace(/.*\//, ""); 156 | user.affiliationName = aff.title; 157 | self.setState({ user: user, w3cidStatus: "showing" }); 158 | }); 159 | }) 160 | ; 161 | }) 162 | .catch(utils.catchHandler) 163 | ; 164 | } 165 | 166 | saveUser () { 167 | this.setState({ modified: false, w3cidStatus: "saving" }); 168 | let user = this.state.user; 169 | fetch( 170 | pp + "api/user/" + this.state.user.username + "/affiliate" 171 | , { 172 | method: "post" 173 | , headers: { "Content-Type": "application/json" } 174 | , credentials: "include" 175 | , body: JSON.stringify({ 176 | affiliation: user.affiliation 177 | , affiliationName: user.affiliationName 178 | , w3cid: user.w3cid 179 | , w3capi: user.w3capi 180 | , groups: user.groups 181 | }) 182 | } 183 | ) 184 | .then(() => { 185 | MessageActions.success("Successfully saved user."); 186 | this.setState({ w3cidStatus: "showing" }); 187 | }) 188 | .catch((e) => { 189 | MessageActions.error("Failure to save info on user: " + e); 190 | this.setState({ modified: true, w3cidStatus: "showing" }); 191 | utils.catchHandler(e); 192 | }) 193 | ; 194 | } 195 | 196 | render () { 197 | let st = this.state 198 | , u = st.user 199 | , content 200 | ; 201 | if (st.status === "loading") { 202 | content = ; 203 | } 204 | else if (st.status === "ready") { 205 | let groupTable = 206 | 207 | { 208 | st.groups.map((g) => { 209 | return 210 | 211 | 217 | ; 218 | }) 219 | } 220 |
    {g.name} 212 | { u.groups && u.groups[g.w3cid] ? 213 | 214 | : 215 | } 216 |
    221 | , w3cid 222 | ; 223 | if (st.w3cidStatus === "showing") { 224 | w3cid = u.w3cid ? 225 | u.w3cid 226 | : 227 | 228 | ; 229 | } 230 | else if (st.w3cidStatus === "loading" || st.w3cidStatus === "setting-user") { 231 | w3cid = ; 232 | } 233 | else if (st.w3cidStatus === "suggesting") { 234 | w3cid =
    235 | 240 | 241 |
    242 | ; 243 | } 244 | content = 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 263 | 264 | { 265 | u.affiliation ? 266 | 267 | 268 | 269 | 270 | : 271 | null 272 | } 273 |
    Name{u.displayName}{ u.admin ? " [admin]" : ""}
    Login{st.username}
    Groups{groupTable}
    W3C ID 261 | {w3cid} 262 |
    Affiliation{u.affiliationName + " [" + u.affiliation + "]"}
    274 | ; 275 | } 276 | return
    277 |

    Edit user

    278 |

    279 | Use this interface to set a the group and company affiliation for a user. 280 | The process is a little baroque due to the nature of the APIs queried for 281 | this purpose: a user needs to be associated with (at least) one group in 282 | order for their W3C ID to be discoverable, and through that the matching 283 | affiliation. 284 |

    285 | {content} 286 |
    287 | 288 |
    289 |
    290 | ; 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /gh.js: -------------------------------------------------------------------------------- 1 | const Octokit = require("@octokit/core").Octokit.plugin(require("@octokit/plugin-paginate-rest").paginateRest) 2 | , async = require("async") 3 | , pg = require("password-generator") 4 | , crypto = require("crypto") 5 | , template = require("./template") 6 | , config = require("./config.json") 7 | ; 8 | 9 | // helpers 10 | 11 | function GH (user) { 12 | if (!user) throw new Error("The GH module requires a user."); 13 | this.user = user; 14 | this.octo = new Octokit({ auth: user.accessToken }); 15 | } 16 | 17 | function makeNewRepo (gh, target, owner, repoShortName, report) { 18 | return gh.octo.request("POST " + target, { 19 | org: owner, 20 | data: { name: repoShortName } 21 | }).then(function ({data: repo}) { 22 | report.push("Repo '" + repoShortName + "' created."); 23 | return repo; 24 | }); 25 | } 26 | 27 | function pickUserRepo (gh, target, owner, repoShortName, report) { 28 | report.push("Looking for repo " + owner + "/" + repoShortName + " to import."); 29 | return gh.octo.request("GET /repos/:owner/:repoShortName", {owner, repoShortName}).then(({data: repo}) => repo); 30 | } 31 | 32 | function newFile (gh, name, content, report) { 33 | return function () { 34 | return gh.octo.request("PUT /repos/:owner/:reponame/contents/:name", { 35 | owner: gh.currentRepo.owner.login, 36 | reponame: gh.currentRepo.name, 37 | name, 38 | data:{ 39 | message: "Adding baseline " + name 40 | , content: new Buffer.from(content).toString("base64") 41 | }}) 42 | .then(function () { 43 | report.push("Added file " + name); 44 | }) 45 | .catch(function () { 46 | report.push("Skipped existing file " + name); 47 | }) 48 | ; 49 | }; 50 | } 51 | 52 | function andify (groups, field) { 53 | var len = groups.length; 54 | if (len === 1) return groups[0][field]; 55 | else if (len === 2) return groups.map(function (g) { return g[field]; }).join(" and "); 56 | else { 57 | var copy = [].concat(groups) 58 | , last = copy.pop() 59 | ; 60 | return copy.map(function (g) { return g[field] + ", "; }) + "and " + last[field]; 61 | } 62 | } 63 | 64 | GH.prototype = { 65 | userOrgs: function(cb) { 66 | const self = this; 67 | self.octo.paginate("GET /user/orgs").then(data => { 68 | cb(null, [self.user.username].concat(data.map(function (org) { return org.login; }))); 69 | }, cb); 70 | } 71 | , userOrgRepos: function (cb) { 72 | const self = this; 73 | self.octo.paginate("GET /user/orgs").then(data => { 74 | Promise.all( 75 | [{login: self.user.username, type: 'user'}].concat(data.map(function (org) { return {login: org.login, type: 'org'}; })) 76 | .map(({type, login}) => { 77 | const target = type === 'user' ? "users" : "orgs"; 78 | return self.octo.paginate("GET /:target/:login/repos", { login, target, per_page: 100}).then(repos => {return {login, repos: repos.map(r => r.name)};}); 79 | })).then(results => { 80 | cb(null, results.reduce(function(a,b) { a[b.login] = b.repos; return a;}, {})); 81 | }, cb); 82 | }, cb); 83 | } 84 | , commentOnPR: function({owner, shortName, num, comment}, cb) { 85 | const w3cBotOcto = new Octokit({ auth: config.w3cBotGHToken }); 86 | w3cBotOcto.request("POST /repos/:owner/:shortName/issues/:num/comments", { owner, shortName, num, data: {body: comment}}).then(({data: comment}) => cb(null, comment), cb); 87 | } 88 | , createRepo: function (data, config, cb) { 89 | this.createOrImportRepo(data, makeNewRepo, newFile, config, cb); 90 | } 91 | , importRepo: function (data, config, cb) { 92 | this.createOrImportRepo(data, pickUserRepo, newFile, config, cb); 93 | } 94 | // data describes the repo to create 95 | // setupAction is a function returning a promise that is called to initiate the creation or 96 | // obtain a pointer to the repo, it must resolve with the octo repo object 97 | // action is a function returning a promise that creates or imports a file, and logs a message 98 | // config is a configuration object with data about the server setup 99 | , createOrImportRepo: function (data, setupAction, action, config, cb) { 100 | // { org: ..., repo: ... } 101 | const self = this; 102 | if (!data.groups.some(g => g)) return cb({json: {message: "No group selected to associate with repository"}}); 103 | var report = [] 104 | , targetPath = (this.user.username === data.org) ? 105 | // we need to treat the current user and an org differently 106 | "/user/repos": "/orgs/:org/repos" 107 | , license 108 | , licensePath 109 | , contributing 110 | , contributingPath 111 | , w3cJSON 112 | , index 113 | , simpleRepo 114 | , readme 115 | , codeOfConduct 116 | , hookURL = config.hookURL || (config.url + config.hookPath) 117 | , tmplData = { 118 | name: andify(data.groups, "name") 119 | , usernames: data.w3cJsonContacts ? JSON.stringify(data.w3cJsonContacts) : null 120 | , w3cid: JSON.stringify(data.groups.map(function (g) { return parseInt(g.w3cid, 10); })) 121 | , repo: data.repo 122 | , displayName: this.user.displayName 123 | , repotype: data.groups[0].groupType === "WG" ? "rec-track" : "cg-report" 124 | } 125 | ; 126 | // collaborations between groups of different types aren't really possible, they don't have 127 | // the same legal regimen, so we only look at the first one 128 | if (data.groups[0].groupType === "CG") { 129 | contributingPath = "CG-contributing.md"; 130 | licensePath = "CG-license.md"; 131 | } 132 | else if (data.groups[0].groupType === "WG") { 133 | if (data.wgLicense === 'doc') { 134 | contributingPath = "WG-CONTRIBUTING.md"; 135 | licensePath = "WG-LICENSE.md"; 136 | } else { 137 | contributingPath = "WG-CONTRIBUTING-SW.md"; 138 | licensePath = "WG-LICENSE-SW.md"; 139 | } 140 | } 141 | else { 142 | var msg = "We currently don't support creating repos for group type: " + data.groups[0].groupType; 143 | return cb({json: {message: msg}}); 144 | } 145 | if (data.includeContributing && contributingPath) { 146 | contributing = template(contributingPath, tmplData); 147 | } 148 | if (data.includeLicense && licensePath) { 149 | license = template(licensePath, tmplData); 150 | } 151 | w3cJSON = data.includeW3cJson ? template("w3c.json", tmplData) : null; 152 | index = data.includeSpec ? template("index.html", tmplData) : null; 153 | readme = data.includeReadme ? template("README.md", tmplData) : null; 154 | codeOfConduct = data.includeCodeOfConduct ? template("CODE_OF_CONDUCT.md", tmplData) : null; 155 | setupAction(self, targetPath, data.org, data.repo, report) 156 | .then(function (repo) { 157 | self.currentRepo = repo; 158 | simpleRepo = { 159 | name: repo.name 160 | , fullName: repo.owner.login + "/" + repo.name 161 | , owner: repo.owner.login 162 | , groups: data.groups.map(function (g) { return g.w3cid; }) 163 | , secret: pg(20) 164 | }; 165 | }) 166 | .then(license ? action(self, "LICENSE.md", license, report) : null) 167 | .then(contributing ? action(self, "CONTRIBUTING.md", contributing, report) : null) 168 | .then(readme ? action(self, "README.md", readme, report) : null) 169 | .then(codeOfConduct ? action(self, "CODE_OF_CONDUCT.md", codeOfConduct, report) : null) 170 | .then(index ? action(self, "index.html", index, report) : null) 171 | .then(w3cJSON ? action(self, "w3c.json", w3cJSON, report) : null) 172 | .then(function () { 173 | return self.octo.request("GET /repos/:owner/:name/hooks", 174 | { 175 | owner: self.currentRepo.owner.login, name: self.currentRepo.name 176 | }) 177 | .then(function ({data: hooks}) { 178 | const hook = (hooks || hooks.length) ? hooks.find(function(h) { return h && h.config && h.config.url === hookURL; }) : null; 179 | 180 | if (!hook) { 181 | return self.octo.request("POST /repos/:owner/:name/hooks", 182 | { 183 | owner: self.currentRepo.owner.login, name: self.currentRepo.name, data: { 184 | name: "web" 185 | , config: { 186 | url: config.hookURL || (config.url + "api/hook") 187 | , content_type: "json" 188 | , secret: simpleRepo.secret 189 | } 190 | , events: ["pull_request", "issue_comment", "repository"] 191 | , active: true 192 | }}) 193 | .then(function () { report.push("Hook installed."); }) 194 | ; 195 | } 196 | else { 197 | return self.octo.request("PATCH /repos/:owner/:name/hooks/:hook", 198 | { 199 | owner: self.currentRepo.owner.login, 200 | name: self.currentRepo.name, 201 | hook: hook.id, 202 | data: { 203 | config: { 204 | url: config.hookURL || (config.url + "api/hook"), 205 | content_type: "json", 206 | secret: simpleRepo.secret 207 | } 208 | } 209 | 210 | }) 211 | .then(function() { report.push("Hook already present. Secret updated"); }) 212 | ; 213 | 214 | } 215 | }) 216 | ; 217 | }) 218 | .then(function () { 219 | cb(null, { actions: report, repo: simpleRepo }); 220 | }) 221 | .catch(function (e) { 222 | cb({code: e.status}); 223 | }) 224 | ; 225 | } 226 | , getRepoContacts: function (repofullname, cb) { 227 | var self = this; 228 | const ret = self.octo 229 | .request("GET /repos/:owner/:name/contents/:file", { 230 | owner: repofullname.split('/')[0], 231 | name: repofullname.split('/')[1], 232 | file: 'w3c.json' 233 | }) 234 | .then(function({data: w3cinfodesc}) { 235 | var w3cinfo = JSON.parse(Buffer.from(w3cinfodesc.content, 'base64').toString('utf8')); 236 | return Promise.all(w3cinfo.contacts.map(function(username) { 237 | return self.octo.request("GET /users/:username", {username}) 238 | .then(({data: user}) => user.email); 239 | })); 240 | }); 241 | if (!cb) return ret; 242 | ret.then(emails => cb(null, emails), cb); 243 | } 244 | , status: function ({owner, shortName, sha, payload}, cb) { 245 | this.octo 246 | .request("POST /repos/:owner/:shortName/statuses/:sha", { 247 | owner, shortName, sha, data: payload 248 | }).then(function () { cb(null); }, cb); 249 | } 250 | , getPrFiles: function(owner, name, prnum, cb) { 251 | const ret = this.octo 252 | .request("GET /repos/:owner/:name/pulls/:prnum/files", {owner, name, prnum}).then(({data: files}) => files); 253 | if (!cb) return ret; 254 | ret.then(files => cb(null, files), cb); 255 | } 256 | , isAdmin: function (username, orgOrUser, cb) { 257 | if (username === orgOrUser) { 258 | return cb(null, true); 259 | } 260 | const ret = this.octo.request("GET /orgs/:orgOrUser/memberships/:username", {username, orgOrUser}) 261 | .then(function ({data: role}) { 262 | return role && role.role === "admin"; 263 | }, (res) => { 264 | if (res.status === 404) return false; 265 | throw(res); 266 | }); 267 | if (!cb) return ret; 268 | return ret.then(u => cb(null, u), cb); 269 | } 270 | , getUser: function (username, cb) { 271 | const ret = this.octo.request("GET /users/:username", {username}) 272 | .then(function ({data: user, headers: headers}) { 273 | var u = { 274 | accessToken: null 275 | , admin: false 276 | , affiliation: null 277 | , affiliationName: null 278 | , blanket: false 279 | , blog: user.blog || "" 280 | , displayName: user.name 281 | , ghID: user.id 282 | , groups: {} 283 | , emails: [] 284 | , photos: [] 285 | , profileUrl: user.html_url 286 | , provider: "github" 287 | , username: username 288 | , w3capi: null 289 | , w3cid: null 290 | , scopes: headers['x-oauth-scopes'] 291 | }; 292 | if (user.email) u.emails.push({ value: user.email }); 293 | if (user.avatar_url) u.photos.push({ value: user.avatar_url }); 294 | return u; 295 | }); 296 | if (!cb) return ret; 297 | return ret.then(u => cb(null, u), cb); 298 | } 299 | }; 300 | 301 | GH.signPayload = function (algo, secret, buffer) { 302 | return algo + "=" + crypto.createHmac(algo, secret).update(buffer).digest("hex"); 303 | }; 304 | 305 | GH.checkPayloadSignature = function (algo, secret, buffer, remotesig) { 306 | const sig = Buffer.from(remotesig, 'utf-8'); 307 | const digest = Buffer.from(GH.signPayload(algo, secret, buffer), 'utf8') 308 | 309 | return (sig.length === digest.length && crypto.timingSafeEqual(digest, sig)); 310 | } 311 | 312 | module.exports = GH; 313 | -------------------------------------------------------------------------------- /application/repo-manager.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import Spinner from "../components/spinner.jsx"; 4 | import {RadioGroup, Radio} from 'react-radio-group'; 5 | import MessageActions from "../actions/messages"; 6 | 7 | require("isomorphic-fetch"); 8 | let utils = require("./utils") 9 | , pp = utils.pathPrefix() 10 | ; 11 | 12 | export default class RepoNew extends React.Component { 13 | constructor (props) { 14 | super(props); 15 | this.state = { 16 | status: "loading" 17 | , orgs: null 18 | , orgRepos: {} 19 | , groups: [] 20 | , disabled: false 21 | , org: null 22 | , repo: null 23 | , repoGroups: [] 24 | , initGroups: null 25 | , mode: "new" 26 | , license: 'doc' 27 | , login: null 28 | , lastAddedRepo: {groups: []} 29 | }; 30 | } 31 | componentWillMount () { 32 | let org, repo; 33 | let mode = this.props.params.mode; 34 | if (["new", "import", "edit"].indexOf(mode) === -1) throw new Error("Unknown repository mode: " + mode); 35 | if (mode === "edit") { 36 | org = this.props.params.owner; 37 | repo = this.props.params.shortname; 38 | } 39 | this.updateState({ mode: mode, org:org, repo: repo }); 40 | } 41 | componentDidMount () { 42 | let orgs; 43 | let st = this.state; 44 | fetch(pp + "api/logged-in", { credentials: "include" }) 45 | .then(utils.jsonHandler) 46 | .then((data) => { 47 | this.updateState({login: data.login}); 48 | }) 49 | .catch(utils.catchHandler); 50 | if (st.mode === "edit") { 51 | fetch(pp + "api/repos") 52 | .then(utils.jsonHandler) 53 | .then(repos => { 54 | const repo = repos.find(r => r.owner === st.org && r.name === st.repo); 55 | if (!repo) { 56 | MessageActions.error("Repository not found"); 57 | return this.updateState({status: "ready"}); 58 | } 59 | this.updateState({initGroups: repo.groups.map(g => g.w3cid)}); 60 | }); 61 | } 62 | fetch(pp + "api/my/last-added-repo", { credentials: "include" }) 63 | .then(utils.jsonHandler) 64 | .then((lastAddedRepo) => { 65 | if (lastAddedRepo) 66 | this.updateState({lastAddedRepo}); 67 | }) 68 | .catch(utils.catchHandler); 69 | fetch(pp + "api/orgs", { credentials: "include" }) 70 | .then(utils.jsonHandler) 71 | .then((data) => { 72 | orgs = data; 73 | return fetch(pp + "api/groups", { credentials: "include" }) 74 | .then(utils.jsonHandler) 75 | .then((data) => { 76 | this.updateState({ orgs: orgs, groups: data, status: "ready" }); 77 | }) 78 | ; 79 | }) 80 | .catch(utils.catchHandler); 81 | fetch(pp + "api/org-repos", { credentials: "include" }) 82 | .then(utils.jsonHandler) 83 | .then(((orgRepos) => { 84 | this.updateState({orgRepos: orgRepos}); 85 | }).bind(this)) 86 | .catch(utils.catchHandler); 87 | } 88 | componentWillReceiveProps (nextProps) { 89 | let nextMode = nextProps.params.mode; 90 | if (nextMode !== this.state.mode) this.updateState({ mode: nextMode }); 91 | } 92 | 93 | updateState(partialState) { 94 | this.setState(Object.assign({}, this.state, partialState)); 95 | } 96 | 97 | updateOrg (ev) { 98 | let org = utils.val(this.refs.org); 99 | this.updateState({org: org}); 100 | } 101 | 102 | onRepoNameChange (ev) { 103 | if (!Object.keys(this.state.orgRepos).length) return; 104 | let org = utils.val(this.refs.org); 105 | switch(this.state.mode) { 106 | case "new": 107 | if (this.state.orgRepos[org].indexOf(ev.target.value) !== -1) { 108 | return ev.target.setCustomValidity("Can't create a repo with that name - already exists"); 109 | } 110 | break; 111 | case "import": 112 | case "edit": 113 | if (this.state.orgRepos[org].indexOf(ev.target.value) === -1) { 114 | return ev.target.setCustomValidity(`Can't ${this.state.mode} a repo with that name - does not exist`); 115 | } 116 | break; 117 | } 118 | ev.target.setCustomValidity(""); 119 | } 120 | 121 | updateGroups () { 122 | let repoGroups = utils.val(this.refs.groups); 123 | this.updateState({repoGroups}); 124 | } 125 | 126 | updateWGLicense (license) { 127 | this.updateState({license}); 128 | } 129 | 130 | 131 | onSubmit (ev) { 132 | ev.preventDefault(); 133 | let st = this.state 134 | , org = utils.val(this.refs.org) 135 | , repo = utils.val(this.refs.repo) 136 | , includeW3cJson = (this.refs.w3c || {}).checked 137 | , w3cJsonContacts = (utils.val(this.refs.contacts) || "").replace(/ /,'').split(',').filter(x=>x) 138 | , includeContributing = (this.refs.contributing || {}).checked 139 | , includeLicense = (this.refs.license || {}).checked 140 | , wgLicense = st.license 141 | , includeReadme = (this.refs.readme || {}).checked 142 | , includeCodeOfConduct = (this.refs.codeOfConduct || {}).checked 143 | , includeSpec = (this.refs.spec || {}).checked 144 | , repoGroups = utils.val(this.refs.groups) 145 | ; 146 | this.setState({ 147 | disabled: true 148 | , status: "submitting" 149 | , org: org 150 | , repo: repo 151 | }); 152 | let apiPath; 153 | switch(st.mode) { 154 | case "new": 155 | apiPath = "api/create-repo"; 156 | break; 157 | case "edit": 158 | apiPath = "api/repos/" + this.props.params.owner + "/" + this.props.params.shortname + "/edit"; 159 | break; 160 | case "import": 161 | apiPath = "api/import-repo"; 162 | break; 163 | } 164 | fetch( 165 | pp + apiPath 166 | , { 167 | method: "post" 168 | , headers: { "Content-Type": "application/json" } 169 | , credentials: "include" 170 | , body: JSON.stringify({ 171 | org 172 | , repo 173 | , groups: repoGroups 174 | , includeW3cJson 175 | , w3cJsonContacts 176 | , includeContributing 177 | , includeLicense 178 | , wgLicense 179 | , includeReadme 180 | , includeCodeOfConduct 181 | , includeSpec 182 | }) 183 | } 184 | ) 185 | .then(utils.jsonHandler) 186 | .then((data) => { 187 | var newState = { 188 | status: "results" 189 | , result: data 190 | , disabled: false 191 | }; 192 | if (!data.error) { 193 | newState.org = ""; 194 | newState.repo = ""; 195 | newState.repoGroups = []; 196 | MessageActions.success("Successfully " + (st.mode === "new" ? "created" : (st.mode === "import" ? "imported" : "edited data on")) + " repository."); 197 | } 198 | else { 199 | MessageActions.error(data.error.json); 200 | newState.result.error = data.error.json.message; 201 | } 202 | this.setState(newState); 203 | return !data.error; 204 | }) 205 | .then ((success) => { 206 | if (success && st.mode !== "edit") { 207 | return fetch( 208 | pp + 'api/my/last-added-repo' 209 | , { 210 | method: "post" 211 | , headers: { "Content-Type": "application/json" } 212 | , credentials: "include" 213 | , body: JSON.stringify({ 214 | groups: repoGroups, 215 | repo, 216 | org, 217 | w3cJsonContacts 218 | }) 219 | } 220 | ); 221 | } 222 | }) 223 | .catch(utils.catchHandler) 224 | ; 225 | } 226 | 227 | render () { 228 | let st = this.state 229 | , results = ""; 230 | let org = st.org || (st.orgs ? st.orgs[0] : null); 231 | let repos = org && Object.keys(st.orgRepos).length ? st.orgRepos[org] : []; 232 | let selectedGroupType = st.repoGroups.length ? st.groups.filter(g => g.w3cid == st.repoGroups[0])[0].groupType : null; 233 | let contributingLink, licenseLink; 234 | if (selectedGroupType) { 235 | contributingLink = selectedGroupType === 'CG' ? 'https://github.com/w3c/licenses/blob/main/CG-CONTRIBUTING.md' : (st.license === 'doc' ? 'https://github.com/w3c/licenses/blob/main/WG-CONTRIBUTING.md' : 'https://github.com/w3c/licenses/blob/main/WG-CONTRIBUTING-SW.md'); 236 | licenseLink = selectedGroupType === 'CG' ? 'https://github.com/w3c/licenses/blob/main/CG-LICENSE.md' : (st.license === 'doc' ? 'https://github.com/w3c/licenses/blob/main/WG-LICENSE.md' : 'https://github.com/w3c/licenses/blob/main/WG-LICENSE-SW.md'); 237 | } 238 | // Files selected by default: 239 | // w3c.json in all cases (since it's used by ashnazg for operation) 240 | // for new repos: CONTRIBUTING, LICENSE 241 | // for new CG repos: +basic respec doc 242 | 243 | let licensePicker = selectedGroupType === 'WG' ? 244 |
    License of the specification in that repository: 245 | 246 | 247 | 248 |
    249 | : ""; 250 | let customization = st.mode === "edit" ? "" :
    251 |

    Add the following files to the repository {st.mode === "import" ? "if they don't already exist (existing files will NOT be overwritten)" : ""}:

    252 | 260 |
    ; 261 | const cgs = st.groups.filter(g => g.groupType === 'CG').sort((g1,g2) => g1.name.localeCompare(g2.name)); 262 | const wgs = st.groups.filter(g => g.groupType === 'WG').sort((g1,g2) => g1.name.localeCompare(g2.name)); 263 | let content = (st.status === "loading") ? 264 | 265 | : 266 |
    267 |
    268 | 269 | 272 | {" / "} 273 | 274 | {(st.mode === "import" || st.mode === "edit") ? 275 | 276 | {repos.map(repo => { 277 | return ; 278 | })} 279 | 280 | : ""} 281 |
    282 |
    283 | 284 | 292 |
    293 | {licensePicker} 294 | {customization} 295 |
    296 | 297 |
    298 |
    299 | ; 300 | if (st.status === "submitting") { 301 | results = ; 302 | } 303 | else if (st.status === "results") { 304 | // XXX need a proper flash message 305 | if (st.result.error) { 306 | results =
    {st.result.error}
    ; 307 | } 308 | else { 309 | results =
    310 |

    311 | The following operations were successfully carried out against your 312 | repository: 313 |

    314 |
      315 | { st.result.actions.map((act) => { return
    • {act}
    • ; }) } 316 |
    317 |

    318 | You can view your { st.mode === "new" ? "newly minted" : "" } repository over 319 | there: {st.result.repo} 320 |

    321 |
    ; 322 | } 323 | } 324 | if (st.mode === "new") { 325 | return
    326 |

    New Repository

    327 |

    328 | Use the form below to create a new repository under either your user or one 329 | of the organisations that you have write access to. There is no requirement 330 | to place your proposal under the w3c organisation; in fact if 331 | a proposal is simply your own, using your personal repository is preferred. 332 | No preference is given to a specification proposal based on the user or 333 | organisation it belongs to. 334 |

    335 | {content} 336 | {results} 337 |
    338 | ; 339 | } 340 | else if (st.mode === "edit") { 341 | return
    342 |

    Update Repository Data

    343 |

    344 | Use the form below to update the owner/name of the repository and/or the group(s) to which this repository is associated with. 345 |

    346 | {content} 347 | {results} 348 |
    349 | ; 350 | } 351 | else { 352 | return
    353 |

    Import Repository

    354 |

    355 | Use this form to turn an unmanaged repository into a W3C-managed repository. This tool does the following: 356 |

      357 |
    1. associates the repository with one or more groups (for patent policy and other integration)
    2. 358 |
    3. checks that pull requests made on the repository from now on match the IPR commitments of the submitters
    4. 359 |
    5. makes it easy to add files that are important to group work (e.g., the code of conduct)
    6. 360 |
    361 |

    362 | {content} 363 | {results} 364 |
    365 | ; 366 | } 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | 2 | # How to develop Ash-Nazg 3 | 4 | This document describes what one needs to know in order to hack on Ash-Nazg. If you are familiar 5 | with Node, [CouchDB][CouchDB], and [React][React] you are already on sane territory but I recommend 6 | you at least skim this document as the local specificities are laid out as well. 7 | 8 | ## IMPORTANT WARNING 9 | 10 | If you are rebuilding the client-side code on a Mac, you are likely to get an incomprehensible error 11 | from [Browserify][Browserify] of the type `Error: EMFILE, open '/some/path'`. That is because the 12 | number of simultaneously open files is bizarrely low on OSX, and Browserify opens a bizarrely high 13 | number of resources concurrently. 14 | 15 | In order to do that, in the environment that runs the build, you will need to run: 16 | 17 | ulimit -n 2560 18 | 19 | If you don't know that, you can waste quite some time. 20 | 21 | ## Overall Architecture 22 | 23 | The repository actually contains two related but generally separate aspects: the server side and the 24 | client side. They do not share code, but communicate over HTTP. This may seem like an off choice, 25 | but it can prove useful if at some point it becomes required to use an 26 | [isomorphic approach](http://nerds.airbnb.com/isomorphic-javascript-future-web-apps/) (which can 27 | rather readily be supported). 28 | 29 | The server side is written in Node, and uses [Express][Express]. It is a pretty typical stack, 30 | serving static content out of `public`, using the Express middleware for sessions, 31 | [Winston][Winston] for logging, etc. 32 | 33 | The database system is CouchDB. It is also used in a straightforward manner, with no reliance on 34 | CouchDB specificities. If needed, it could be ported to another system. 35 | 36 | The client side is written using React, making lightweight use of the [Flux][Flux] architecture, and 37 | is built using Browserify. React is its own way of thinking about Web applications that has its own 38 | learning curve (and can require a little bit of retooling of one's editor for the [JSX][JSX] part) 39 | but once you start using it it is hard to go back. It's the first framework I find to be worth the 40 | hype since jQuery (and for completely different reasons). 41 | 42 | No CSS framework is used; but the CSS does get built too using [cleancss][cleancss] (for modularity 43 | and minification). 44 | 45 | ## Setting Up 46 | 47 | Installation is straightforward: 48 | 49 | git clone https://github.com/w3c/ash-nazg 50 | cd ash-nazg 51 | npm install -d 52 | 53 | You now need to configure the system so that it can find various bits and pieces. For this create a 54 | `config.json` at the root, with the following content: 55 | 56 | ``` 57 | { 58 | // the root URL, this is what I use on my development machine 59 | "url": "http://ash.bast/" 60 | // the full URL to the GitHub hook; locally I use ngrok to expose that to the world 61 | // (see below for details about ngrok). In production this can be inferred from url+hookPath 62 | , "hookURL": "http://ashnazg.ngrok.io/api/hook" 63 | // the local path for the GitHub hook 64 | , "hookPath": "api/hook" 65 | // pick a port to use 66 | , "serverPort": 3043 67 | // you need a secret to seed the sessions 68 | , "sessionSecret": "Some secret phrase" 69 | // the client ID and secret you get from GitHub 70 | , "ghClientID": "deadbeef" 71 | , "ghClientSecret": "d3adb33f" 72 | // set to true if you want logging to the console (false in production) 73 | , "logToConsole": true 74 | // username and password for Couch 75 | , "couchAuth": { 76 | "username": "robin" 77 | , "password": "some!cool@password" 78 | } 79 | // the database name in Couch 80 | , "couchDB": "ashnazg" 81 | // address from which notifications are set 82 | , "notifyFrom": "foo@example.com" 83 | // w3cbot GitHub token with `public_repo` privileges to comment on the PR 84 | , "w3cBotGHToken": "1234" 85 | } 86 | ``` 87 | 88 | Now, with CouchDB is already up and running, you want to run: 89 | 90 | node store.js 91 | node tools/add-admin.js yourGitHubUsername 92 | 93 | This installs all the design documents that Couch needs. Whenever you change the design documents, 94 | just run `store.js` again. You only need to create an admin user on a fresh database; after that 95 | other admins can be minted through the UI. 96 | 97 | To send notifications of failures, ash-nazg assumes sendmail is installed and properly configured on the server. 98 | 99 | Running the server is as simple as: 100 | 101 | npm run start 102 | 103 | If you are going to develop however, that isn't the best way of running the server. If you are 104 | touching several aspects (CSS, client, server) you will want to have several terminals open. 105 | 106 | When developing the server code, you want to run: 107 | 108 | npm run watch-server 109 | 110 | This will start a [nodemon][nodemon] instance that will monitor the changes you make to the *server* 111 | code, and restart it for you. 112 | 113 | When developing client code, you want to run: 114 | 115 | npm run watch 116 | 117 | This will also use nodemon to monitor the CSS and JS/JSX to rebuild them as needed. Be warned that 118 | the JS build can take a second or two, so if nothing changes because you reload too fast that's why. 119 | You can `watch-js` and `watch-css` separately if you want to. 120 | 121 | One of the issues with developing on one's box is that it is not typically accessible over the Web 122 | for outside services to interact with. If you are trying to get events from repositories on GitHub, 123 | you will need to expose yourself to the Web. You may already have your preferred way of doing that, 124 | but in case you don't you can use [ngrok][ngrok] (which is what I do). In order to expose your 125 | service through ngrok, just run 126 | 127 | ```bash 128 | npm run expose # Or, if you don't have an ngrok paid plan: 129 | node_modules/ngrok/bin/ngrok http 3043 130 | ``` 131 | 132 | Note that you don't need that for regular development, you only need to be exposed if you want to 133 | receive GitHub events. 134 | 135 | ## Production deployment 136 | 137 | You will want a slightly different `config.json`; the one in hatchery is serviceable. 138 | 139 | You don't want to use `npm run` in production; instead use [pm2][pm2]. A configuration is provided 140 | for it in `pm2-production.json` (it's what's used on hatchery). 141 | 142 | Make sure you create an admin user as described above. 143 | 144 | 145 | ## The CouchDB Design 146 | 147 | A small set of design documents are used in CouchDB, and they are all very simple. They are basic 148 | maps to index the data. You can find them all under `store.js` in `setupDDocs()`. There are: 149 | 150 | * users, that can be queried by username or affiliation; 151 | * groups, queried through their W3C ID or type (WG, etc.); 152 | * secrets (each repository hook has a separate secret so that a rogue repository can be forgotten 153 | about without compromising the others), queried by repository name; 154 | * tokens (that allow us to impersonate users), queried by username; 155 | * repos, queried by name; and 156 | * PRs, queried by any of: repository name and PR number, date, status (open or closed), group that 157 | they below to, or affiliation of contributors. 158 | 159 | ## Server Code Layout 160 | 161 | The server makes use of several files. 162 | 163 | ### `server.js` 164 | 165 | This is the primary entry point, and it does quite a few things. It could be factored out. 166 | 167 | It makes use of Passport and its attendant GitHub login strategy in order to support GitHub logins. 168 | This is basically an OAuth service. When a new user logs in, their user gets created in the DB based 169 | on the information that GitHub provides through Passport. 170 | 171 | There are also Express endpoints for when OAuth completes and we need to handle the actual login at 172 | our end (`/auth/github` and `/auth/github/callback`). The code handles redirections so that the user 173 | should always return to the page that they initially had to log into. 174 | 175 | The server uses long-lived sessions, that are stored as files. This could be replaced with a DB, but 176 | so long as the traffic is reasonable it should not be a problem. 177 | 178 | There is a `logout` endpoint that simply kills the session, and a `logged-in` one that can tell 179 | whether the current user is logged in (and an admin or not). 180 | 181 | Many endpoints simply talk to the store in order to CRUD the data. Nothing fancy. 182 | 183 | The complicated parts are those that handle the interaction with GitHub beyond just the login. 184 | 185 | `makeCreateOrImportRepo()` will drive the `gh` component in order to (yes) create or import a 186 | repository. It will create and store a secret unique to the hook attached to that repo, to make sure 187 | that the secret can leak without enabling people to fake input from any monitored repo. It will also 188 | store the GitHub token that is allowed to manipulate this repo so that we can interact with it even 189 | in the user's absence. Once all works out it adds the repository to the DB. 190 | 191 | The GitHub hooks handling is nasty, sadly because it has to be (see `prStatus()`). This needs to: 192 | 193 | * Find the repository and bail if we're not monitoring it 194 | * Find a token that allows us to set the status of PRs on that repo 195 | * Set the status to pending 196 | * Get existing contributors if the PR is already known about (since it can be updated) 197 | * Look up all the contributors to see if they're allowed to contribute 198 | * Set the status of the PR (and store it) based on the contributors' acceptability 199 | 200 | The handling of the incoming hook is also amusing. Basically, hooks are signed so that we can be 201 | sure they are really coming from GitHub. But since we have a different secret per repo we need to 202 | look inside the payload to figure out which secret to use to validate the signature. Yet we can't 203 | use the normal Express JSON middleware because that will get rid of the incoming bytes, making 204 | signature validation impossible. 205 | 206 | Once we have the repo, the secret, signature validation, and it's the right kind of event we pass 207 | the data on. 208 | 209 | A few endpoints also talk to the `w3capi` library in order to make it easier to use the W3C API. 210 | Nothing fancy. 211 | 212 | Finally, a number of endpoints just map to `showIndex()`. This is there because we use the History 213 | API, which means we can get requests with those paths but they should all just serve the index page. 214 | 215 | 216 | ### `store.js` 217 | 218 | This is a very straightforward access point to CouchDB, built atop the [cradle][cradle] library. 219 | When ran directly it creates the DB and sets up the design documents; otherwise it's a library that 220 | can be used to access the content of the DB. 221 | 222 | Overall it could use some DRY love; a lot of its methods look very much like one another. 223 | 224 | There is no specific handling of conflicts, they should just fail. 225 | 226 | Object types are labelled with a `type` field, and the `id` field is used to know where to store 227 | each object. The `type` field is what the design documents map on. 228 | 229 | 230 | ### `gh.js` 231 | 232 | This library handles most of the interactions with GitHub, on top of the [octokat][octokat] library. 233 | Most of these interactions are simple and linear. 234 | 235 | 236 | ### `log.js` 237 | 238 | This is a simple wrapper that exposes an already-built instance of Winston, configured to log to the 239 | console, file, or both. It's easy to add other logging targets if need be. 240 | 241 | 242 | ## Client Code Layout 243 | 244 | ### `app.css` and `css/fonts.css` 245 | 246 | These are very simple CSS files. They are merged together (along with imported dependencies) and 247 | stored under `public/css`. Therefore that's what their paths are relative to. 248 | 249 | There is no magic and no framework. The complete built CSS is ~5K. 250 | 251 | ### `app.jsx` 252 | 253 | This is the entry point for the JS application. Most of what it does is to import things and get 254 | them set up. 255 | 256 | The whole client JS is written in ES6, JSX, React. This can be surprising at first, but it is a 257 | powerful combo. 258 | 259 | The root `AshNazg` component listens for changes to the login state of the user (through the Login 260 | store) in order to change the navigation bar that it controls. All it renders is basically: the 261 | application title, a simple layout grid (that uses the [ungrid][ungrid] CSS approach), the 262 | navigation bar, and an empty space for the routed component. It also renders the "flash" area that 263 | shows messages for successful operations or errors. 264 | 265 | Finally, the router is set up with a number of paths mapping to imported components. 266 | 267 | ### `components/*.jsx` 268 | 269 | The JSX files under `components/` are simple, reusable components. At some point they should probably be extracted into a shared library that can be reused across W3C applications. 270 | 271 | Most of them are extremely simple and largely there to keep the JSX readable, without having to rely 272 | excessively on `div`s and classes. 273 | 274 | #### `application.jsx` 275 | 276 | A simple layout wrapper, with a title, that just renders its children. Used to render routed 277 | components into. 278 | 279 | #### `col.jsx` and `row.jsx` 280 | 281 | Very simple row and column items that use ungrid. Nothing fancy. 282 | 283 | #### `nav-box.jsx` and `nav-item.jsx` 284 | 285 | Made to be used as a navigation column or as drop down menus, the boxes have titles that label a 286 | navigation section, the items are basically just navigation entries. 287 | 288 | #### `spinner.jsx` 289 | 290 | This is a simple loading/progress spinner (that uses `img/spinner.svg`). If Chrome drops SMIL 291 | support this will need to be replaced by something else. It understands the `prefix` option in order 292 | to still work when the application is not running at the site's root (an improvement would be to 293 | just inline the SVG). 294 | 295 | It also accepts a `size="small"` property which renders it at half size. 296 | 297 | #### `flash-list.jsx` 298 | 299 | This just renders the list of success/error messages that are stored in the message store. 300 | 301 | ### `stores/*.js` and `actions/*.js` 302 | 303 | One architectural approach that works well with React is known as Flux. At its heart it is a simple 304 | idea to handle events and data in an application, in such a manner that avoids tangled-up messes. 305 | 306 | The application (typically driven by the user) can trigger an **action**, usually with attached 307 | data. An example from the code are error messages that can be emitted pretty much anywhere in the 308 | application (ditto success messages). 309 | 310 | Actions are all sent towards the **dispatcher** (which we reuse from the basic Flux implementation). 311 | The dispatcher makes these available to whoever wants to listen. This is similar to pub/sub, except that an event's full trip is taken into consideration, and it only ever travels in one direction. 312 | 313 | Stores listen to actions, and keep any data that the application might need handy (either locally or 314 | by accessing it when needed). For the error/success messages, the store just keeps them around until 315 | they are dismissed, which means that navigation across components will still render the messages in 316 | the store. 317 | 318 | Finally, components can listen to changes in stores, and react to them so as to update thei 319 | rendering. 320 | 321 | Overall, this application should make use of actions and stores a lot more. Developing it further 322 | will likely require refactoring along those lines. One of the great things with React is that the 323 | components are isolated in such a manner that you can follow bad practices inside of a given 324 | component without damaging the rest of the application. Not that this is recommended, but it does 325 | allow one to experiment with what a given component should do before refactoring it. I would not say 326 | that the components in this application follow bad practices, but they could be refactored to use 327 | stores and actions in order to be cleaner and more testable. 328 | 329 | #### `actions/messages.js` and `actions/user.js` 330 | 331 | These are actions. These modules can just be imported by any component that wishes to carry out such 332 | actions, without having to know anything about whether or how the result gets stored, or how it 333 | might influence the rest of the application (it's completely fire-and-forget). 334 | 335 | The `messages.js` action module supports `error()` and `success()` messages, and can `dismiss()` a 336 | given message. The `user.js` action module supports `login()` and `logout()` actions corresponding 337 | to what the user does. 338 | 339 | #### `stores/login.js` and `stores/message.js` 340 | 341 | The `login` store keeps information about whether the user is logged in (and an administrator), and 342 | handles the logging out when requested. The `message` store keeps a list of error and success 343 | messages that haven't been dismissed. 344 | 345 | ### The `application/*.jsx` components 346 | 347 | These are non-reusable components that are specific to this applications. 348 | 349 | #### `welcome.jsx` 350 | 351 | Just a static component with the welcome text; this is only a component because it's the simplest 352 | way of encapsulating anything that may be rendered in the application area. 353 | 354 | #### `login.jsx` 355 | 356 | A very simple component that explains the login process and links to the OAuth processor. 357 | 358 | #### `logout-button.jsx` 359 | 360 | A button that can be used (and reused) anywhere (in our case, it's part of the navigation). When 361 | clicked it dispatches a `logout` action. 362 | 363 | #### `repo-list.jsx` 364 | 365 | A simple component that fetches the list of repositories that are managed and lists them. 366 | 367 | #### `repo-manager.jsx` 368 | 369 | A more elaborate component that handles both creation and importing of repositories into the system. 370 | It handles the dialog for create/import, including listing the organisations that the user has 371 | access to and which groups a repository can be managed by. 372 | 373 | All of the useful repository-management logic is on the server side, but this reacts to the results. 374 | 375 | #### `pr/last-week.jsx` 376 | 377 | The list of pull requests that were processed one way or another during the last week. This 378 | component can also filter them dynamically by affiliation. 379 | 380 | #### `pr/open.jsx` 381 | 382 | The list of currently open PRs. 383 | 384 | #### `pr/view.jsx` 385 | 386 | The detailed view of a single PR, with various affordances to manage it. 387 | 388 | #### `admin/users.jsx` and `admin/user-line.jsx` 389 | 390 | The list of users known to the system, with some details and links to edit them. The `user-line` 391 | component just renders one line in the list of users. 392 | 393 | #### `admin/add-user.jsx` 394 | 395 | A very simple dialog that can be used to add users with. 396 | 397 | #### `admin/edit-user.jsx` 398 | 399 | One of the more intricate parts of the system. Brings in data from GitHub, the W3C API, and the 400 | system in order to bridge together various bits of information about the user, such as the groups 401 | they belong to, their real name, their affiliation, their W3C and GitHub IDs, etc. 402 | 403 | #### `admin/groups.jsx` and `admin/group-line.jsx` 404 | 405 | Lists all the groups known to the W3C API, and makes it possible to add those that are not already 406 | in the system. Each line in the table is rendered by `group-line.jsx`. 407 | 408 | #### `admin/pick-user.jsx` 409 | 410 | A very simple interface that links to `add-user` in order to add a user. 411 | 412 | ## Test suite 413 | 414 | The [test suite](./test/) only deals with the server-side of the app. 415 | 416 | It uses mocha as its test runner, [supertest][supertest] to test the responses from the various routes, and [nock][nock] to mock the third-party APIs the app relies on (Github API, W3C API). 417 | 418 | To run the test suite, you need to have a running instance of couchdb, and initialize it with `node store.js "./test/config-test.json"`; if your couchdb requires a login/password for admin, you should add it to the `config-test.json` file as an entry of the form of: 419 | ```json 420 | "couchAuth": { 421 | "username": "foo" 422 | , "password": "bar" 423 | } 424 | ``` 425 | 426 | [CouchDB]: http://couchdb.apache.org/ 427 | [Express]: http://expressjs.com/ 428 | [Midgard]: https://github.com/w3c/midgard 429 | [React]: https://facebook.github.io/react/docs/getting-started.html 430 | [Flux]: http://facebook.github.io/flux/ 431 | [Browserify]: http://browserify.org/ 432 | [JSX]: https://facebook.github.io/react/docs/displaying-data.html 433 | [cleancss]: https://github.com/jakubpawlowicz/clean-css 434 | [nodemon]: https://github.com/remy/nodemon 435 | [ngrok]: https://ngrok.com/ 436 | [pm2]: https://github.com/Unitech/pm2 437 | [cradle]: https://github.com/flatiron/cradle 438 | [Winston]: http://github.com/flatiron/winston 439 | [ungrid]: http://chrisnager.github.io/ungrid/ 440 | [octokat]: https://github.com/philschatz/octokat.js/ 441 | [supertest]: https://github.com/visionmedia/supertest 442 | [nock]: https://github.com/node-nock/nock 443 | --------------------------------------------------------------------------------