├── .babelrc ├── .circleci └── config.yml ├── .env ├── .eslintrc ├── .github └── dependabot.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .stylelintrc ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── Procfile ├── README.md ├── bin ├── fetchFragments.js └── server.js ├── components ├── AMODashCount.js ├── AMODashCountGroup.js ├── ActiveLink.js ├── Contrib.js ├── DashBlank.js ├── DashCount.js ├── DashCountGroup.js ├── Engineer.js ├── HeaderLink.js └── YesNoBool.js ├── config ├── mime.types ├── nginx.conf.erb ├── sec-headers-base.conf └── sec-headers.conf ├── jest.config.js ├── jest.setup.js ├── jsconfig.json ├── lib ├── bzapi.js ├── const.js ├── fragmentTypes.json ├── ghapi.js ├── serverSWR.js └── utils │ ├── contrib.js │ ├── index.js │ ├── milestones.js │ ├── projects.js │ └── sort.js ├── next.config.js ├── package.json ├── pages ├── _app.js ├── _document.js ├── api │ ├── bz-issue-counts.js │ ├── bz-need-infos.js │ ├── bz-whiteboard-tags.js │ ├── gh-contrib-welcome.js │ ├── gh-good-first-bugs.js │ ├── gh-issue-counts.js │ ├── gh-maybe-good-first-bugs.js │ ├── gh-milestone-issues.js │ ├── gh-projects.js │ └── gh-team.js ├── contrib │ ├── contrib-welcome.js │ ├── good-first-bugs.js │ └── maybe-good-first-bugs.js ├── dashboards │ ├── amo.js │ └── webext.js ├── index.js ├── milestones │ ├── [milestone].js │ ├── index.js │ └── latest.js └── projects │ ├── [year] │ └── [quarter].js │ ├── index.js │ └── latest.js ├── public ├── favicon.ico ├── robots.txt └── static │ └── images │ └── blue-berror.svg ├── styles └── globals.scss ├── tests ├── components │ ├── ActiveLink.test.js │ ├── DashCount.test.js │ ├── DashCountGroup.test.js │ ├── Engineer.test.js │ ├── HeaderLink.test.js │ └── YesNoBool.test.js ├── fixtures │ ├── bz-issue-count-single.js │ ├── bz-issue-counts.js │ ├── bz-need-infos-single.js │ ├── bz-need-infos.js │ ├── bz-whiteboard-tags-single.js │ ├── bz-whiteboard-tags.js │ ├── gh-contrib-welcome.js │ ├── gh-good-first-bugs.js │ ├── gh-issue-counts.js │ ├── gh-maybe-good-first-bugs.js │ ├── gh-milestone-issues.js │ ├── gh-projects.js │ └── gh-team.js ├── lib │ ├── const.test.js │ ├── serverSWR.test.js │ └── utils │ │ ├── contrib.test.js │ │ ├── index.test.js │ │ ├── milestones.test.js │ │ ├── projects.test.js │ │ └── sort.test.js ├── mocks │ ├── fileMock.js │ └── styleMock.js └── pages │ ├── _app.test.js │ ├── api │ ├── bz-issue-counts.test.js │ ├── bz-need-infos.test.js │ ├── bz-whiteboard-tags.test.js │ ├── gh-contrib-welcome.test.js │ ├── gh-good-first-bugs.test.js │ ├── gh-issue-counts.test.js │ ├── gh-maybe-good-first-bugs.test.js │ ├── gh-milestone-issues.test.js │ ├── gh-projects.test.js │ └── gh-team.test.js │ ├── contrib │ ├── contrib-welcome.test.js │ ├── good-first-bugs.test.js │ └── maybe-good-first-bugs.test.js │ ├── dashboards │ ├── amo.test.js │ └── webext.test.js │ ├── index.test.js │ ├── milestones │ ├── [milestone].test.js │ ├── index.test.js │ └── latest.test.js │ └── projects │ ├── [year] │ └── [quarter].test.js │ ├── index.test.js │ └── latest.test.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | codecov: codecov/codecov@3.2.4 5 | 6 | references: 7 | defaults: &defaults 8 | working_directory: ~/addons-pm 9 | docker: 10 | - image: cimg/node:16.19 11 | 12 | restore_build_cache: &restore_build_cache 13 | restore_cache: 14 | keys: 15 | - yarn-packages-{{ checksum "yarn.lock" }} 16 | 17 | run_yarn_install: &run_yarn_install 18 | run: 19 | name: Install Dependencies 20 | command: yarn install --immutable 21 | 22 | save_build_cache: &save_build_cache 23 | save_cache: 24 | key: yarn-packages-{{ checksum "yarn.lock" }} 25 | paths: 26 | - ~/.cache/yarn 27 | 28 | jobs: 29 | test: 30 | <<: *defaults 31 | steps: 32 | - checkout 33 | - *restore_build_cache 34 | - *run_yarn_install 35 | - *save_build_cache 36 | - run: yarn run prettier-ci 37 | - run: yarn run lint 38 | - run: yarn run stylelint 39 | - run: yarn run test-ci 40 | - codecov/upload 41 | - run: yarn run build 42 | 43 | workflows: 44 | version: 2 45 | default-workflow: 46 | jobs: 47 | - test 48 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # DO NOT INCLUDE SECRETS IN THIS FILE! 2 | # This file is committed and should contain defaults only. 3 | 4 | HOSTNAME=localhost 5 | PORT=3000 6 | API_HOST=http://$HOSTNAME:$PORT 7 | 8 | NEXT_TELEMETRY_DISABLED=1 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "amo", 4 | "plugin:amo/recommended" 5 | ], 6 | "env": { 7 | "jest/globals": true 8 | }, 9 | "parser": "@babel/eslint-parser", 10 | "plugins": [ 11 | "jest" 12 | ], 13 | "globals": { 14 | "fetch": true 15 | }, 16 | "rules": { 17 | // These rules are not compatible with Prettier. 18 | "indent": "off", 19 | "operator-linebreak": "off", 20 | "react/jsx-one-expression-per-line": "off", 21 | // Modify rules. 22 | "jsx-a11y/anchor-is-valid": [ 23 | "error", 24 | { 25 | "components": ["Link"], 26 | "specialLink": ["hrefLeft", "hrefRight"], 27 | "aspects": ["invalidHref", "preferButton"] 28 | } 29 | ], 30 | "no-import-assign": "off", 31 | "react/function-component-definition": "off", 32 | "react/no-unescaped-entities": "off", 33 | "react/prop-types": "off", 34 | "import/no-extraneous-dependencies": "off", 35 | "import/no-unresolved": "off", 36 | "import/extensions": "off", 37 | "operator-assignment": "off", 38 | "no-param-reassign": "off", 39 | "no-nested-ternary": "off", 40 | // This can be off as Next imports React. 41 | "react/react-in-jsx-scope": "off", 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | open-pull-requests-limit: 99 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | .coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | .env.*.local 34 | 35 | # vercel 36 | .vercel 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # exclude everything by default 2 | *.* 3 | # exclude these directories 4 | /dist/ 5 | /node_modules/ 6 | /build/ 7 | /config/ 8 | /coverage/ 9 | Procfile 10 | # Allow files we want to process 11 | !*.js 12 | !*.md 13 | !*.scss 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "proseWrap": "never", 6 | "quoteProps": "preserve" 7 | } 8 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard-scss", 4 | "stylelint-config-suitcss", 5 | "stylelint-config-prettier-scss" 6 | ], 7 | "rules": { 8 | "at-rule-empty-line-before": ["always", { 9 | "ignore": [ 10 | "after-comment", 11 | "blockless-after-same-name-blockless", 12 | "inside-block", 13 | ], 14 | }], 15 | "at-rule-no-unknown": [true, { 16 | "ignoreAtRules": [ 17 | "content", 18 | "else", 19 | "for", 20 | "function", 21 | "if", 22 | "include", 23 | "mixin", 24 | "return", 25 | "warn", 26 | ], 27 | }], 28 | "block-closing-brace-newline-after": ["always", { 29 | "ignoreAtRules": ["if", "else"], 30 | }], 31 | "declaration-colon-newline-after": null, 32 | "function-url-quotes": ["always"], 33 | "function-disallowed-list": ["random"], 34 | "indentation": null, 35 | "max-nesting-depth": [3, { 36 | "ignore": ["blockless-at-rules"], 37 | }], 38 | "no-descending-specificity": null, 39 | "rule-empty-line-before": ["always", { 40 | "except": [ 41 | "first-nested" 42 | ], 43 | "ignore": [ 44 | "after-comment" 45 | ] 46 | }], 47 | "selector-class-pattern": null, 48 | "string-quotes": null, 49 | "value-list-comma-newline-after": null, 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details, please read the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 4 | 5 | ## How to Report 6 | 7 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 8 | 9 | 15 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/start-nginx node bin/server.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Add-ons Project Manager 2 | 3 | [![CircleCI](https://circleci.com/gh/mozilla/addons-pm.svg?style=svg)](https://circleci.com/gh/mozilla/addons-pm) [![codecov](https://codecov.io/gh/mozilla/addons-pm/branch/master/graph/badge.svg)](https://codecov.io/gh/mozilla/addons-pm) 4 | 5 | This app is a view on the org level projects specific to add-ons. 6 | 7 | ## Development Requirements 8 | 9 | - Uses node LTS 10 | - Uses yarn 11 | 12 | ### Installation and start-up 13 | 14 | - `yarn install` 15 | - `yarn dev` 16 | 17 | `yarn dev` will start the Next.js development environment. 18 | 19 | ### Environment Variables 20 | 21 | The server requires setting some required environment variables. To do this create a `.env.local` file in the root of your checkout (Note: `.env.local` files are .gitignored) and add the following: 22 | 23 | #### GH_TOKEN 24 | 25 | ```yaml 26 | GH_TOKEN=this-should-be-a-personal-access-token 27 | ``` 28 | 29 | You can generate a personal access token token here: https://github.com/settings/tokens and you'll need the following scopes: 30 | 31 | ``` 32 | public_repo, read:org 33 | ``` 34 | 35 | #### BZ_USERS 36 | 37 | For needinfos to work the `BZ_USERS` env var should contain nicknames and Bugzilla users. 38 | 39 | ```yaml 40 | BZ_USERS={"name": "bz-email@example.com", "name2": "bz-email@example.com"} 41 | ``` 42 | 43 | ### Deployment 44 | 45 | The current method used to deploy to Heroku is via git. To do that you'll need to setup the relevant branches and then, as long as you have rights to the apps in Heroku, you'll be able to do a release by pushing to the relevant remote repo. 46 | 47 | Pushing to the a remote repo will start the deployment process and you'll get feedback in the terminal. For more details on this process see: https://devcenter.heroku.com/articles/git 48 | 49 | #### Requirements 50 | 51 | Install the [heroku CLI](https://devcenter.heroku.com/articles/heroku-cli). 52 | 53 | Add heroku git repos: 54 | 55 | ```sh 56 | git remote add staging https://git.heroku.com/addons-pm-staging.git 57 | git remote add production https://git.heroku.com/addons-pm.git 58 | ``` 59 | 60 | #### Pushing to stage 61 | 62 | Double check you're on the revision you want to deploy. 63 | 64 | ```sh 65 | heroku login 66 | git push staging 67 | ``` 68 | 69 | #### Pushing to prod 70 | 71 | Double check you're currently on the revision you want to deploy. 72 | 73 | ```sh 74 | heroku login 75 | git push production 76 | ``` 77 | -------------------------------------------------------------------------------- /bin/fetchFragments.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | const fetch = require('node-fetch'); 5 | const { loadEnvConfig } = require('@next/env'); 6 | 7 | // Require env variables. 8 | const dev = process.env.NODE_ENV !== 'production'; 9 | loadEnvConfig(path.join(__dirname, '../'), dev); 10 | 11 | const headers = { 'Content-Type': 'application/json' }; 12 | 13 | if (process.env.GH_TOKEN) { 14 | headers.Authorization = `token ${process.env.GH_TOKEN}`; 15 | } else { 16 | throw new Error('No GH_TOKEN found'); 17 | } 18 | 19 | fetch(`https://api.github.com/graphql`, { 20 | method: 'POST', 21 | headers, 22 | body: JSON.stringify({ 23 | variables: {}, 24 | query: ` 25 | { 26 | __schema { 27 | types { 28 | kind 29 | name 30 | possibleTypes { 31 | name 32 | } 33 | } 34 | } 35 | } 36 | `, 37 | }), 38 | }) 39 | .then((result) => result.json()) 40 | .then((result) => { 41 | // here we're filtering out any type information unrelated to unions or interfaces 42 | const filteredData = result.data.__schema.types.filter( 43 | (type) => type.possibleTypes !== null, 44 | ); 45 | result.data.__schema.types = filteredData; 46 | fs.writeFile( 47 | path.join(__dirname, '../lib/fragmentTypes.json'), 48 | JSON.stringify(result.data), 49 | (err) => { 50 | if (err) { 51 | // eslint-disable-next-line no-console 52 | console.error('Error writing fragmentTypes file', err); 53 | } else { 54 | // eslint-disable-next-line no-console 55 | console.log('Fragment types successfully extracted!'); 56 | } 57 | }, 58 | ); 59 | }); 60 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | const { createServer } = require('http'); 2 | const { parse } = require('url'); 3 | const fs = require('fs'); 4 | 5 | const next = require('next'); 6 | 7 | const dev = process.env.NODE_ENV !== 'production'; 8 | const app = next({ dev }); 9 | const handle = app.getRequestHandler(); 10 | 11 | app.prepare().then(() => { 12 | createServer((req, res) => { 13 | const parsedUrl = parse(req.url, true); 14 | handle(req, res, parsedUrl); 15 | }).listen('/tmp/nginx.socket', (err) => { 16 | if (err) { 17 | throw err; 18 | } 19 | // eslint-disable-next-line no-console 20 | console.log('Add-ons PM NextJS Server is running'); 21 | fs.openSync('/tmp/app-initialized', 'w'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /components/AMODashCount.js: -------------------------------------------------------------------------------- 1 | import { oneLineTrim } from 'common-tags'; 2 | 3 | import DashCount from './DashCount'; 4 | 5 | export default function AMODashCount(props) { 6 | const repo = props.repo.replace(/_/g, '-'); 7 | let warningLimit; 8 | let issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues? 9 | utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen`; 10 | 11 | if (props.title.includes('untriaged')) { 12 | issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues? 13 | utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20-label%3A%22priority%3Ap1%22%20 14 | -label%3A%22priority%3Ap2%22%20-label%3A%22priority%3Ap3%22%20 15 | -label%3A%22priority%3Ap4%22%20-label%3A%22priority%3Ap5%22`; 16 | warningLimit = 15; 17 | } 18 | if (props.title.includes('prod_bug')) { 19 | issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues? 20 | utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22type%3Aprod_bug%22`; 21 | warningLimit = 1; 22 | } 23 | if (props.title.includes('p1')) { 24 | issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues? 25 | utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:p1%22`; 26 | warningLimit = 1; 27 | } 28 | if (props.title.includes('p2')) { 29 | issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues? 30 | utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:p2%22`; 31 | warningLimit = 1; 32 | } 33 | if (props.title.includes('p3')) { 34 | issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues? 35 | utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:p3%22`; 36 | warningLimit = undefined; 37 | } 38 | if (props.title.includes('open prs')) { 39 | issuesLink = `https://github.com/mozilla/${repo}/pulls?q=is%3Apr+is%3Aopen`; 40 | warningLimit = 10; 41 | } 42 | 43 | return ( 44 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/AMODashCountGroup.js: -------------------------------------------------------------------------------- 1 | import AMODashCount from './AMODashCount'; 2 | import DashCountGroup from './DashCountGroup'; 3 | 4 | export default function AMODashCountGroup(props) { 5 | function renderDashCounts() { 6 | const { repo, issueCounts } = props; 7 | const counts = []; 8 | counts.push( 9 | AMODashCount({ 10 | title: 'total open issues', 11 | count: issueCounts.total_issues.totalCount, 12 | repo, 13 | }), 14 | ); 15 | 16 | counts.push( 17 | AMODashCount({ 18 | title: 'untriaged issues', 19 | count: 20 | issueCounts.total_issues.totalCount - issueCounts.triaged.totalCount, 21 | repo, 22 | }), 23 | ); 24 | 25 | Object.keys(issueCounts).forEach((count) => { 26 | const { totalCount } = issueCounts[count]; 27 | const title = count.replace('_', ' '); 28 | if ( 29 | !count.startsWith('__') && 30 | count !== 'total_issues' && 31 | count !== 'triaged' && 32 | count !== 'description' 33 | ) { 34 | counts.push( 35 | AMODashCount({ 36 | title, 37 | count: totalCount, 38 | repo, 39 | }), 40 | ); 41 | } 42 | }); 43 | return counts; 44 | } 45 | 46 | return ( 47 | 51 | {renderDashCounts()} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/ActiveLink.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import PropTypes from 'prop-types'; 3 | import Link from 'next/link'; 4 | import React, { Children } from 'react'; 5 | 6 | const ActiveLink = ({ 7 | children, 8 | activeClassName = 'active', 9 | ...props 10 | } = {}) => { 11 | const { asPath } = useRouter(); 12 | const child = Children.only(children); 13 | const childClassName = child.props.className || ''; 14 | 15 | // pages/index.js will be matched via props.href 16 | // pages/about.js will be matched via props.href 17 | // pages/[slug].js will be matched via props.as 18 | const className = 19 | asPath === props.href || asPath === props.as 20 | ? `${childClassName} ${activeClassName}`.trim() 21 | : childClassName; 22 | 23 | return ( 24 | 25 | {React.cloneElement(child, { 26 | className: className || null, 27 | })} 28 | 29 | ); 30 | }; 31 | 32 | ActiveLink.propTypes = { 33 | activeClassName: PropTypes.string.isRequired, 34 | }; 35 | 36 | export default ActiveLink; 37 | -------------------------------------------------------------------------------- /components/Contrib.js: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | import { useRouter } from 'next/router'; 3 | import { Container, Nav, Navbar, Table } from 'react-bootstrap'; 4 | import TimeAgo from 'react-timeago'; 5 | import { AlertIcon, LinkIcon } from '@primer/octicons-react'; 6 | import { dateSort, numericSort, sortData } from 'lib/utils/sort'; 7 | import YesNoBool from 'components/YesNoBool'; 8 | import HeaderLink from 'components/HeaderLink'; 9 | import ActiveLink from 'components/ActiveLink'; 10 | 11 | // These views display assignment columns. 12 | const sortConfig = { 13 | priority: {}, 14 | title: {}, 15 | repo: {}, 16 | assigned: { 17 | sortFunc: numericSort, 18 | }, 19 | mentorAssigned: { 20 | sortFunc: numericSort, 21 | }, 22 | updatedAt: { 23 | sortFunc: dateSort, 24 | }, 25 | }; 26 | 27 | function renderRows({ data, hasAssignments }) { 28 | const rows = []; 29 | const colSpan = 6; 30 | 31 | if (!data) { 32 | return ( 33 | 34 | Loading... 35 | 36 | ); 37 | } 38 | 39 | if (data.length === 0) { 40 | return ( 41 | 42 | 43 |
44 |

45 | No issues found! Time to deploy the team to find some quality 46 | bugs! 47 |

48 |
49 | 50 | 51 | ); 52 | } 53 | 54 | for (let i = 0; i < data.length; i++) { 55 | const issue = data[i]; 56 | 57 | rows.push( 58 | 59 | 60 | 61 | {issue.priority ? issue.priority.toUpperCase() : } 62 | 63 | 64 | 65 | 66 | #{issue.number}: {issue.title}{' '} 67 | 68 | 69 | 70 | {issue.repository.name.replace('addons-', '')} 71 | {hasAssignments ? ( 72 | 73 | 80 | 81 | ) : null} 82 | {hasAssignments ? ( 83 | 84 | 91 | 92 | ) : null} 93 | 94 | 95 | 96 | , 97 | ); 98 | } 99 | return rows; 100 | } 101 | 102 | const Contrib = (props) => { 103 | const router = useRouter(); 104 | const { dir, sort } = router.query; 105 | const { contribData, hasAssignments } = props; 106 | 107 | let data = contribData; 108 | if (sort) { 109 | data = sortData({ data, columnKey: sort, direction: dir, sortConfig }); 110 | } 111 | 112 | return ( 113 |
114 | 115 | Contributions 116 | 117 | 123 | 152 | 153 | 154 | 155 | 156 | 157 | 160 | 163 | 166 | {hasAssignments ? ( 167 | 170 | ) : null} 171 | {hasAssignments ? ( 172 | 178 | ) : null} 179 | 182 | 183 | 184 | {renderRows({ data, hasAssignments })} 185 |
158 | 159 | 161 | 162 | 164 | 165 | 168 | 169 | 173 | 177 | 180 | 181 |
186 |
187 |
188 | ); 189 | }; 190 | 191 | export default Contrib; 192 | -------------------------------------------------------------------------------- /components/DashBlank.js: -------------------------------------------------------------------------------- 1 | import { Card } from 'react-bootstrap'; 2 | 3 | export default function DashBlank() { 4 | return ( 5 | 6 | 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /components/DashCount.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { Card } from 'react-bootstrap'; 3 | 4 | export default function DashCount(props) { 5 | let extraTitle = ''; 6 | if (typeof props.warningLimit !== 'undefined') { 7 | extraTitle = ` (Warning Threshold: count >= ${props.warningLimit})`; 8 | } 9 | 10 | return ( 11 | 20 | {props.title.toUpperCase()} 21 | 22 |
= props.warningLimit, 29 | total: props.title.includes('total open'), 30 | })} 31 | > 32 | 33 | 34 | 35 | 42 | {props.count} 43 | 44 | 45 | 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /components/DashCountGroup.js: -------------------------------------------------------------------------------- 1 | import { Card } from 'react-bootstrap'; 2 | 3 | export default function DashCountGroup(props) { 4 | return ( 5 |
10 | 11 | 12 | {props.title} 13 | 14 | 15 | 16 | {props.description} 17 | 18 | 19 | 20 | {props.children} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/Engineer.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Image } from 'react-bootstrap'; 3 | 4 | export default function Engineer(props) { 5 | const { member, year, quarter } = props; 6 | return ( 7 | 13 | 14 | {member.login} 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/HeaderLink.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import Link from 'next/link'; 3 | 4 | export default function HeaderLink(props) { 5 | const router = useRouter(); 6 | const { columnKey, linkText } = props; 7 | const { sort, dir } = router.query; 8 | const classDir = dir === 'asc' ? 'asc' : 'desc'; 9 | let linkDir = 'desc'; 10 | let className = 'sort-direction'; 11 | if (sort === columnKey) { 12 | linkDir = dir === 'desc' ? 'asc' : 'desc'; 13 | className = `${className} ${classDir}`; 14 | } 15 | 16 | const query = { 17 | // Keep existing query params. 18 | ...router.query, 19 | // Override ones related to sort. 20 | dir: linkDir, 21 | sort: columnKey, 22 | }; 23 | 24 | return ( 25 | 30 | {linkText} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/YesNoBool.js: -------------------------------------------------------------------------------- 1 | export default function YesNoBool(props) { 2 | const { bool, extraClasses } = props; 3 | const yesOrNo = bool === true ? 'yes' : 'no'; 4 | let classNames = [yesOrNo]; 5 | if (extraClasses && extraClasses[yesOrNo]) { 6 | classNames = [...classNames, ...extraClasses[yesOrNo]]; 7 | } 8 | return {yesOrNo.toUpperCase()}; 9 | } 10 | -------------------------------------------------------------------------------- /config/mime.types: -------------------------------------------------------------------------------- 1 | types { 2 | text/html html htm shtml; 3 | text/css css; 4 | text/xml xml; 5 | text/cache-manifest manifest appcache; 6 | image/gif gif; 7 | image/jpeg jpeg jpg; 8 | application/x-javascript js; 9 | application/atom+xml atom; 10 | application/rss+xml rss; 11 | 12 | text/mathml mml; 13 | text/plain txt; 14 | text/vnd.sun.j2me.app-descriptor jad; 15 | text/vnd.wap.wml wml; 16 | text/x-component htc; 17 | 18 | image/png png; 19 | image/tiff tif tiff; 20 | image/vnd.wap.wbmp wbmp; 21 | image/x-icon ico; 22 | image/x-jng jng; 23 | image/x-ms-bmp bmp; 24 | image/svg+xml svg; 25 | 26 | application/java-archive jar war ear; 27 | application/mac-binhex40 hqx; 28 | application/msword doc; 29 | application/pdf pdf; 30 | application/postscript ps eps ai; 31 | application/rtf rtf; 32 | application/vnd.ms-excel xls; 33 | application/vnd.ms-powerpoint ppt; 34 | application/vnd.wap.wmlc wmlc; 35 | application/vnd.google-earth.kml+xml kml; 36 | application/vnd.google-earth.kmz kmz; 37 | application/x-7z-compressed 7z; 38 | application/x-cocoa cco; 39 | application/x-java-archive-diff jardiff; 40 | application/x-java-jnlp-file jnlp; 41 | application/x-makeself run; 42 | application/x-perl pl pm; 43 | application/x-pilot prc pdb; 44 | application/x-rar-compressed rar; 45 | application/x-redhat-package-manager rpm; 46 | application/x-sea sea; 47 | application/x-shockwave-flash swf; 48 | application/x-stuffit sit; 49 | application/x-tcl tcl tk; 50 | application/x-x509-ca-cert der pem crt; 51 | application/x-xpinstall xpi; 52 | application/xhtml+xml xhtml; 53 | application/zip zip; 54 | 55 | application/octet-stream bin exe dll; 56 | application/octet-stream deb; 57 | application/octet-stream dmg; 58 | application/octet-stream eot; 59 | application/octet-stream iso img; 60 | application/octet-stream msi msp msm; 61 | 62 | audio/midi mid midi kar; 63 | audio/mpeg mp3; 64 | audio/ogg ogg; 65 | audio/x-realaudio ra; 66 | 67 | video/3gpp 3gpp 3gp; 68 | video/mpeg mpeg mpg; 69 | video/quicktime mov; 70 | video/x-flv flv; 71 | video/x-mng mng; 72 | video/x-ms-asf asx asf; 73 | video/x-ms-wmv wmv; 74 | video/x-msvideo avi; 75 | } 76 | -------------------------------------------------------------------------------- /config/nginx.conf.erb: -------------------------------------------------------------------------------- 1 | daemon off; 2 | 3 | worker_processes <%= ENV['NGINX_WORKERS'] || 4 %>; 4 | 5 | events { 6 | use epoll; 7 | accept_mutex on; 8 | worker_connections 1024; 9 | } 10 | 11 | http { 12 | gzip on; 13 | gzip_comp_level 2; 14 | gzip_min_length 512; 15 | 16 | server_tokens off; 17 | 18 | log_format l2met 'measure#nginx.service=$request_time request_id=$http_x_request_id'; 19 | access_log <%= ENV['NGINX_ACCESS_LOG_PATH'] || 'logs/nginx/access.log' %> l2met; 20 | error_log <%= ENV['NGINX_ERROR_LOG_PATH'] || 'logs/nginx/error.log' %>; 21 | 22 | include mime.types; 23 | default_type application/octet-stream; 24 | sendfile on; 25 | 26 | # Must read the body in 5 seconds. 27 | client_body_timeout 5; 28 | 29 | proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off; 30 | 31 | upstream nextjs_upstream { 32 | server unix:/tmp/nginx.socket fail_timeout=0; 33 | } 34 | 35 | server { 36 | listen <%= ENV['PORT'] %>; 37 | server_name _; 38 | keepalive_timeout 5; 39 | port_in_redirect off; 40 | 41 | more_clear_headers 'X-Powered-By'; 42 | 43 | proxy_http_version 1.1; 44 | proxy_set_header Upgrade $http_upgrade; 45 | proxy_set_header Connection 'upgrade'; 46 | proxy_set_header Host $host; 47 | proxy_cache_bypass $http_upgrade; 48 | 49 | if ($http_x_forwarded_proto != 'https') { 50 | rewrite ^ https://$host$request_uri? permanent; 51 | } 52 | 53 | # A default restrictive CSP that should always be overriden by location blocks. 54 | include sec-headers-base.conf; 55 | 56 | # All the JS / CSS served by next. 57 | location /_next/static { 58 | # Next.js serves far-futures expires itself. 59 | # This caching will have nginx serve statics (after the first request) 60 | # rather than hitting the app-server. 61 | proxy_cache STATIC; 62 | proxy_pass http://nextjs_upstream; 63 | 64 | # For testing cache - remove before deploying to production 65 | add_header X-Cache-Status $upstream_cache_status; 66 | 67 | # Full sec headers so error pages work. 68 | include sec-headers.conf; 69 | } 70 | 71 | # Serves static files added to public/static 72 | location /static { 73 | proxy_cache STATIC; 74 | proxy_ignore_headers Cache-Control; 75 | proxy_cache_valid 60m; 76 | 77 | add_header X-Cache-Status $upstream_cache_status; 78 | 79 | # Full sec headers so error pages work. 80 | include sec-headers.conf; 81 | 82 | proxy_pass http://nextjs_upstream; 83 | } 84 | 85 | location /api { 86 | # Full sec headers so error pages work. 87 | include sec-headers.conf; 88 | proxy_pass http://nextjs_upstream; 89 | } 90 | 91 | location / { 92 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 93 | proxy_set_header Host $http_host; 94 | 95 | include sec-headers.conf; 96 | 97 | proxy_pass http://nextjs_upstream; 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /config/sec-headers-base.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options DENY always; 2 | add_header X-Content-Type-Options nosniff always; 3 | add_header Strict-Transport-Security max-age=31536000 always; 4 | add_header Content-Security-Policy " 5 | default-src 'none'; 6 | base-uri 'none'; 7 | form-action 'none'; 8 | object-src 'none'" always; 9 | add_header referrer-policy no-referrer-when-downgrade; 10 | -------------------------------------------------------------------------------- /config/sec-headers.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options DENY always; 2 | add_header X-Content-Type-Options nosniff always; 3 | add_header Strict-Transport-Security max-age=31536000 always; 4 | add_header Content-Security-Policy "default-src 'none'; base-uri 'self'; form-action 'none'; object-src 'none'; connect-src 'self'; font-src 'self'; script-src 'self'; img-src 'self' https://*.githubusercontent.com/u/; style-src 'self' 'unsafe-inline'" always; 5 | add_header referrer-policy no-referrer-when-downgrade; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // Jest.config.js 2 | module.exports = { 3 | // Automatically clear mock calls and instances between every test 4 | clearMocks: true, 5 | collectCoverageFrom: ['**/*.{js,jsx}'], 6 | // The directory where Jest should output its coverage files 7 | coveragePathIgnorePatterns: [ 8 | '/node_modules/', 9 | '/.next/', 10 | '/coverage/', 11 | '/jest.config.js', 12 | '/jest.setup.js', 13 | '/next.config.js', 14 | '/tests/', 15 | ], 16 | // Module lookup ordering. 17 | moduleDirectories: ['', 'node_modules'], 18 | // A list of paths to modules that run some code to configure or set up the testing 19 | // framework before each test 20 | moduleNameMapper: { 21 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 22 | '/tests/mocks/fileMock.js', 23 | '\\.(scss|css)$': '/tests/mocks/styleMock.js', 24 | }, 25 | setupFilesAfterEnv: ['./jest.setup.js'], 26 | }; 27 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Jest.setup.js 2 | import '@testing-library/jest-dom'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | 5 | // Set __NEXT_TRAILING_SLASH to configure trailing slashes for tests 6 | // Temp workaround for https://github.com/vercel/next.js/issues/16094 7 | const { trailingSlash } = require('./next.config'); 8 | 9 | process.env = { ...process.env, __NEXT_TRAILING_SLASH: trailingSlash }; 10 | 11 | // Turn off console for tests. 12 | jest.spyOn(global.console, 'log').mockImplementation(() => jest.fn()); 13 | jest.spyOn(global.console, 'debug').mockImplementation(() => jest.fn()); 14 | 15 | global.fetch = require('fetch-mock'); 16 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/bzapi.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string'; 2 | 3 | import serverSWR from './serverSWR'; 4 | 5 | export const baseWEBURL = 'https://bugzilla.mozilla.org/buglist.cgi'; 6 | const baseAPIURL = 'https://bugzilla.mozilla.org/rest/bug'; 7 | 8 | const needsInfoParams = { 9 | include_fields: 'id', 10 | f1: 'flagtypes.name', 11 | f2: 'requestees.login_name', 12 | o1: 'casesubstring', 13 | o2: 'equals', 14 | v1: 'needinfo?', 15 | v2: null, // Placeholder for the user email. 16 | }; 17 | 18 | const whiteboardTagParams = { 19 | status_whiteboard_type: 'allwordssubstr', 20 | status_whiteboard: null, // Placeholder for the whiteboard tag to look for. 21 | }; 22 | 23 | const webExtOnlyParams = { 24 | component: [ 25 | 'Add-ons Manager', 26 | 'Android', 27 | 'Compatibility', 28 | 'Developer Outreach', 29 | 'Developer Tools', 30 | 'Experiments', 31 | 'Frontend', 32 | 'General', 33 | 'Request Handling', 34 | 'Storage', 35 | 'Themes', 36 | 'Untriaged', 37 | ], 38 | product: ['Toolkit', 'WebExtensions'], 39 | }; 40 | 41 | const openBugParams = { 42 | resolution: '---', 43 | bug_status: ['ASSIGNED', 'NEW', 'REOPENED', 'UNCONFIRMED'], 44 | }; 45 | 46 | export function fetchIssueCount({ priority, product, bug_severity } = {}) { 47 | const params = { 48 | product, 49 | priority, 50 | bug_severity, 51 | count_only: true, 52 | limit: 0, 53 | }; 54 | 55 | /* istanbul ignore next */ 56 | if (params.bug_priority && params.bug_severity) { 57 | throw new Error('Query only severity or priority independently'); 58 | } 59 | 60 | if (bug_severity) { 61 | delete params.priority; 62 | } 63 | 64 | if (priority) { 65 | delete params.bug_severity; 66 | } 67 | 68 | if (product === 'Toolkit') { 69 | params.component = 'Add-ons Manager'; 70 | } 71 | 72 | const apiURL = `${baseAPIURL}?${queryString.stringify({ 73 | ...params, 74 | ...openBugParams, 75 | })}`; 76 | const webParams = { ...params, ...openBugParams }; 77 | delete webParams.count_only; 78 | const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`; 79 | return serverSWR( 80 | apiURL, 81 | async () => { 82 | const res = await fetch(apiURL, { 83 | headers: { 'Content-Type': 'application/json' }, 84 | }); 85 | const json = await res.json(); 86 | return { count: json.bug_count, url: webURL }; 87 | }, 88 | { 89 | hashKey: true, 90 | }, 91 | ); 92 | } 93 | 94 | export function fetchNeedInfo(email) { 95 | const apiParams = { ...needsInfoParams, ...webExtOnlyParams }; 96 | apiParams.v2 = email; 97 | 98 | const apiURL = `${baseAPIURL}?${queryString.stringify(apiParams)}`; 99 | return serverSWR( 100 | apiURL, 101 | async () => { 102 | const result = await fetch(apiURL, { 103 | headers: { 'Content-Type': 'application/json' }, 104 | }); 105 | return result.json(); 106 | }, 107 | { 108 | hashKey: true, 109 | }, 110 | ); 111 | } 112 | 113 | export function fetchWhiteboardTag(whiteboardTag) { 114 | const apiParams = { 115 | ...whiteboardTagParams, 116 | ...webExtOnlyParams, 117 | ...openBugParams, 118 | status_whiteboard: whiteboardTag, 119 | count_only: true, 120 | }; 121 | 122 | const webParams = { ...apiParams }; 123 | delete webParams.count_only; 124 | 125 | const apiURL = `${baseAPIURL}?${queryString.stringify(apiParams)}`; 126 | const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`; 127 | 128 | return serverSWR( 129 | apiURL, 130 | async () => { 131 | const result = await fetch(apiURL, { 132 | headers: { 'Content-Type': 'application/json' }, 133 | }); 134 | const json = await result.json(); 135 | return { count: json.bug_count, url: webURL }; 136 | }, 137 | { 138 | hashKey: true, 139 | }, 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /lib/const.js: -------------------------------------------------------------------------------- 1 | const validYears = [ 2 | '2017', 3 | '2018', 4 | '2019', 5 | '2020', 6 | '2021', 7 | '2022', 8 | '2023', 9 | '2024', 10 | '2025', 11 | ]; 12 | 13 | module.exports = { 14 | validYears, 15 | validYearRX: new RegExp(`^(?:${validYears.join('|')})$`), 16 | validMilestoneRX: new RegExp( 17 | `^(?${validYears.join( 18 | '|', 19 | )})-(?0[1-9]|1[0-2])-(?0[1-9]|[1-2]\\d|3[0-1])$`, 20 | ), 21 | validQuarterRX: /^Q[1-4]$/, 22 | validAMOProjectTeamMembers: ['bobsilverberg', 'diox', 'eviljeff'], 23 | colors: { 24 | blocked: '#ffa500', 25 | closed: '#98ff98', 26 | contrib: '#C9B4F9', 27 | inProgress: '#fff176', 28 | invalid: '#EDEDED', 29 | priority: '#E92332', 30 | verified: '#00A21D', 31 | prReady: '#ffc107', 32 | open: '#666966', 33 | p1: '#ff0039', 34 | p2: '#d70022', 35 | p3: '#a4000f', 36 | p4: '#5a0002', 37 | p5: '#3e0200', 38 | }, 39 | invalidStates: [ 40 | 'state:invalid', 41 | 'state:duplicate', 42 | 'state:works_for_me', 43 | 'state:wontfix', 44 | ], 45 | priorities: ['p1', 'p2', 'p3', 'p4', 'p5'], 46 | contribRepos: [ 47 | 'mozilla/addons', 48 | 'mozilla/addons-code-manager', 49 | 'mozilla/addons-blog', 50 | 'mozilla/addons-server', 51 | 'mozilla/addons-frontend', 52 | 'mozilla/addons-linter', 53 | 'mozilla/dispensary', 54 | 'mozilla/extension-workshop', 55 | 'mozilla/sign-addon', 56 | 'mozilla/web-ext', 57 | 'mozilla/webextension-polyfill', 58 | 'mozilla/FirefoxColor', 59 | ], 60 | bugzilla: { 61 | priorities: ['--', 'P1', 'P2'], 62 | severities: ['normal', '--', 'N/A', 'S1', 'S2'], 63 | products: ['Toolkit', 'WebExtensions'], 64 | whiteboardTags: [ 65 | '[mv3-m1]', 66 | '[mv3-m2]', 67 | '[mv3-m3]', 68 | '[mv3-future]', 69 | 'stockwell', 70 | 'addons-ux', 71 | 'prod_bug', 72 | ], 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /lib/ghapi.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client'; 2 | import { createHttpLink } from 'apollo-link-http'; 3 | import { 4 | InMemoryCache, 5 | IntrospectionFragmentMatcher, 6 | } from 'apollo-cache-inmemory'; 7 | import hash from 'object-hash'; 8 | 9 | import introspectionQueryResultData from './fragmentTypes.json'; 10 | import serverSWR from './serverSWR'; 11 | 12 | export default function createClient() { 13 | const headers = {}; 14 | /* istanbul ignore next */ 15 | if (process.env.NODE_ENV !== 'test') { 16 | if (process.env.GH_TOKEN) { 17 | headers.Authorization = `token ${process.env.GH_TOKEN}`; 18 | } else { 19 | throw new Error('No GH_TOKEN found'); 20 | } 21 | } 22 | 23 | const fragmentMatcher = new IntrospectionFragmentMatcher({ 24 | introspectionQueryResultData, 25 | }); 26 | 27 | // For fetches to work correctly we use a new client instance for 28 | // each request to avoid stale data. 29 | const gqlClient = new ApolloClient({ 30 | link: createHttpLink({ 31 | uri: 'https://api.github.com/graphql', 32 | headers, 33 | }), 34 | cache: new InMemoryCache({ 35 | fragmentMatcher, 36 | }), 37 | }); 38 | 39 | // Client with serverSWR wrapper to carry out in memory caching of the original API response 40 | // from githubs GraphQL API. 41 | const client = { 42 | query: async ({ query, variables }) => { 43 | // Create a hash based on the query with variables. 44 | const keyHash = hash( 45 | { queryAsString: query.loc.source.body.toString(), variables }, 46 | { algorithm: 'sha256' }, 47 | ); 48 | return serverSWR(keyHash, async () => { 49 | const result = await gqlClient.query({ query, variables }); 50 | return result; 51 | }); 52 | }, 53 | }; 54 | 55 | return client; 56 | } 57 | -------------------------------------------------------------------------------- /lib/serverSWR.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file provides a fetch that uses node-cache under the covers to replicate SWR on the server. 3 | * This enables caching to be handled in a specific way for API calls we're carrying 4 | * out from the server. 5 | * 6 | */ 7 | 8 | const crypto = require('crypto'); 9 | 10 | const NodeCache = require('node-cache'); 11 | 12 | const cache = new NodeCache(); 13 | 14 | // timeToStale is the amount of time that should pass before 15 | // a cached results is considered stale. 16 | // Default: 5 Minutes. 17 | const TIME_TO_STALE_DEFAULT = 5 * 60 * 1000; 18 | // staleMargin is added to the timstamp on stale requests 19 | // to temporarily increase the time it's considered stale. This stops a 20 | // lot of revalidation requests being made. 21 | // Default: 10 Secs 22 | const STALE_MARGIN_DEFAULT = 10 * 1000; 23 | 24 | const serverSWR = async ( 25 | key, 26 | fetcher, 27 | { 28 | timeToStale = TIME_TO_STALE_DEFAULT, 29 | staleMargin = STALE_MARGIN_DEFAULT, 30 | swrCache = cache, 31 | hashKey = false, 32 | } = {}, 33 | ) => { 34 | if (hashKey) { 35 | key = crypto.createHash('sha256').update(key).digest('hex'); 36 | } 37 | 38 | async function fetchAndCache() { 39 | const result = await fetcher(); 40 | const cacheObject = { 41 | timestamp: new Date().getTime(), 42 | response: result, 43 | }; 44 | 45 | swrCache.set(key, cacheObject); 46 | return result; 47 | } 48 | 49 | if (swrCache.has(key)) { 50 | const cachedData = swrCache.get(key); 51 | if (typeof cachedData !== 'undefined') { 52 | const currentTime = new Date().getTime(); 53 | if (currentTime > cachedData.timestamp + timeToStale) { 54 | cachedData.timestamp = new Date().getTime() + staleMargin; 55 | cachedData.response.stale = true; 56 | // Re-insert the cache entry to update the timestamp. 57 | // This prevents lots of revalidation requests. 58 | swrCache.set(key, cachedData); 59 | // Refetch async without waiting for the result. 60 | fetchAndCache(); 61 | } 62 | return cachedData.response; 63 | } 64 | } 65 | return fetchAndCache(); 66 | }; 67 | 68 | module.exports = serverSWR; 69 | -------------------------------------------------------------------------------- /lib/utils/contrib.js: -------------------------------------------------------------------------------- 1 | import { hasLabelContainingString } from 'lib/utils'; 2 | import { priorities } from 'lib/const'; 3 | 4 | export function formatContribData(data) { 5 | const issues = []; 6 | data.forEach((item) => { 7 | const issue = { 8 | ...item.issue, 9 | priority: '', 10 | assigned: false, 11 | mentorAssigned: false, 12 | }; 13 | const labels = issue.labels.nodes || []; 14 | priorities.forEach((priority) => { 15 | if (hasLabelContainingString(labels, priority)) { 16 | issue.priority = priority; 17 | } 18 | }); 19 | if (hasLabelContainingString(labels, 'contrib: assigned')) { 20 | issue.assigned = true; 21 | } 22 | if (hasLabelContainingString(labels, 'contrib: mentor assigned')) { 23 | issue.mentorAssigned = true; 24 | } 25 | if (issue.repository && issue.repository.name) { 26 | issue.repo = issue.repository.name; 27 | } 28 | issues.push(issue); 29 | }); 30 | return issues; 31 | } 32 | -------------------------------------------------------------------------------- /lib/utils/index.js: -------------------------------------------------------------------------------- 1 | import DOMPurifyLib from 'dompurify'; 2 | import queryString from 'query-string'; 3 | 4 | // Create an DOMPurify instance in a universal way. 5 | let DOMPurify; 6 | if (typeof window === 'undefined') { 7 | // eslint-disable-next-line global-require 8 | const { JSDOM } = require('jsdom'); 9 | const { window } = new JSDOM(''); 10 | DOMPurify = DOMPurifyLib(window); 11 | } else { 12 | DOMPurify = DOMPurifyLib; 13 | } 14 | 15 | DOMPurify.addHook('afterSanitizeAttributes', (node) => { 16 | if ('target' in node) { 17 | node.setAttribute('target', '_blank'); 18 | node.setAttribute('rel', 'noopener noreferrer'); 19 | } 20 | }); 21 | 22 | export const { sanitize } = DOMPurify; 23 | 24 | export function hasLabel(issueLabels, labelOrLabelList) { 25 | const labels = issueLabels || []; 26 | if (Array.isArray(labelOrLabelList)) { 27 | return labels.some((item) => labelOrLabelList.includes(item.name)); 28 | } 29 | return !!labels.find((label) => label.name === labelOrLabelList); 30 | } 31 | 32 | export function hasLabelContainingString(issueLabels, string) { 33 | const labels = issueLabels || []; 34 | const rx = new RegExp(string); 35 | return !!labels.find((label) => rx.test(label.name)); 36 | } 37 | 38 | export function hexToRgb(hex) { 39 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 40 | return result 41 | ? { 42 | r: parseInt(result[1], 16), 43 | g: parseInt(result[2], 16), 44 | b: parseInt(result[3], 16), 45 | } 46 | : {}; 47 | } 48 | 49 | export function colourIsLight(hex) { 50 | const { r, g, b } = hexToRgb(hex); 51 | // Counting the perceptive luminance 52 | // human eye favors green color... 53 | const a = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255; 54 | return a < 0.5; 55 | } 56 | 57 | export function getApiURL(path, queryParams) { 58 | if (!path.startsWith('/api')) { 59 | throw new Error(`Path should start with '/api'`); 60 | } 61 | const host = process.env.API_HOST || ''; 62 | let apiUrl = `${host}${path}`; 63 | if (queryParams) { 64 | apiUrl = `${apiUrl}?${queryString.stringify(queryParams)}`; 65 | } 66 | return apiUrl; 67 | } 68 | -------------------------------------------------------------------------------- /lib/utils/milestones.js: -------------------------------------------------------------------------------- 1 | import { oneLineTrim } from 'common-tags'; 2 | import { colors, priorities } from 'lib/const'; 3 | import { colourIsLight, hasLabel, hasLabelContainingString } from 'lib/utils'; 4 | 5 | /* 6 | * This function should return the next nearest release 7 | * date including if the release date is today. 8 | * dayOfWeek: Sunday is 0, Monday is 1 etc... 9 | */ 10 | export function getNextMilestone({ 11 | dayOfWeek = 4, 12 | startDate = new Date(), 13 | } = {}) { 14 | if (startDate.getDay() === dayOfWeek) { 15 | return startDate; 16 | } 17 | const resultDate = new Date(startDate.getTime()); 18 | resultDate.setDate( 19 | startDate.getDate() + ((7 + dayOfWeek - startDate.getDay() - 1) % 7) + 1, 20 | ); 21 | return resultDate; 22 | } 23 | 24 | /* 25 | * Formats a date object into a milestone format YYYY.MM.DD 26 | * Handles zero filling so 2019.1.1 will be 2019.01.01 27 | */ 28 | export function formatDateToMilestone(date) { 29 | return oneLineTrim`${date.getFullYear()}- 30 | ${(date.getMonth() + 1).toString().padStart(2, '0')}- 31 | ${date.getDate().toString().padStart(2, '0')}`; 32 | } 33 | 34 | /* 35 | * Computes an object with pagination data based on starting day of week and defaulting 36 | * to the current date. 37 | * 38 | */ 39 | export function getMilestonePagination({ 40 | dayOfWeek = 4, 41 | startDate = new Date(), 42 | } = {}) { 43 | // The nearest release milestone to the starting point. 44 | let nextMilestone = getNextMilestone({ dayOfWeek, startDate }); 45 | const prev = new Date( 46 | nextMilestone.getFullYear(), 47 | nextMilestone.getMonth(), 48 | nextMilestone.getDate() - 7, 49 | ); 50 | 51 | // Set next Milestone to 7 days time if we're starting on current milestone date already. 52 | if ( 53 | formatDateToMilestone(startDate) === formatDateToMilestone(nextMilestone) 54 | ) { 55 | nextMilestone = new Date( 56 | nextMilestone.getFullYear(), 57 | nextMilestone.getMonth(), 58 | nextMilestone.getDate() + 7, 59 | ); 60 | } 61 | 62 | // The current milestone closest to today. 63 | const currentMilestone = getNextMilestone(dayOfWeek); 64 | 65 | return { 66 | // The milestone before the startDate. 67 | prevFromStart: formatDateToMilestone(prev), 68 | // The startDate milestone (might not be a typical release day). 69 | start: formatDateToMilestone(startDate), 70 | // The milestone after the startDate. 71 | nextFromStart: formatDateToMilestone(nextMilestone), 72 | // The current closest milestone to today. 73 | current: formatDateToMilestone(currentMilestone), 74 | }; 75 | } 76 | 77 | // Set priority if there's a priority label associated with the issue. 78 | export function setIssuePriorityProp(issue) { 79 | const labels = (issue.labels && issue.labels.nodes) || []; 80 | issue.priority = null; 81 | priorities.forEach((priority) => { 82 | if (hasLabelContainingString(labels, priority)) { 83 | issue.priority = priority; 84 | } 85 | }); 86 | } 87 | 88 | // Set the repo name directly on the issue. 89 | export function setRepoProp(issue) { 90 | if (issue.repository && issue.repository.name) { 91 | issue.repo = issue.repository.name; 92 | } 93 | } 94 | 95 | // Update project info, 96 | export function setProjectProps(issue) { 97 | issue.hasProject = false; 98 | if ( 99 | issue.projectCards && 100 | issue.projectCards.nodes && 101 | issue.projectCards.nodes.length 102 | ) { 103 | issue.hasProject = true; 104 | issue.projectUrl = issue.projectCards.nodes[0].project.url; 105 | issue.projectName = issue.projectCards.nodes[0].project.name; 106 | } 107 | } 108 | 109 | // Add assignee prop pointing to the login of the first assignee. 110 | export function setAssigneeProp(issue) { 111 | const labels = (issue.labels && issue.labels.nodes) || []; 112 | issue.isContrib = false; 113 | issue.assignee = '00_unassigned'; 114 | if (issue.assignees.nodes.length) { 115 | issue.assignee = issue.assignees.nodes[0].login; 116 | } else if (hasLabelContainingString(labels, 'contrib: assigned')) { 117 | issue.isContrib = true; 118 | issue.assignee = '01_contributor'; 119 | } 120 | } 121 | 122 | export function setReviewerDetails(issue) { 123 | issue.reviewers = []; 124 | const reviewersListSeen = []; 125 | 126 | if (issue.state === 'CLOSED') { 127 | issue.timelineItems.edges.forEach((timelineItem) => { 128 | if (!timelineItem.event.source.reviews) { 129 | // This is not a pull request item. 130 | return; 131 | } 132 | const { bodyText } = timelineItem.event.source; 133 | const issueTestRx = new RegExp(`Fix(?:es)? #${issue.number}`, 'i'); 134 | 135 | // Only add the review if the PR contains a `Fixes #num` or `Fix #num` line that 136 | // matches the original issue. 137 | if (issueTestRx.test(bodyText)) { 138 | timelineItem.event.source.reviews.edges.forEach( 139 | ({ review: { author } }) => { 140 | if (!reviewersListSeen.includes(author.login)) { 141 | reviewersListSeen.push(author.login); 142 | issue.reviewers.push({ 143 | author, 144 | prLink: timelineItem.event.source.permalink, 145 | }); 146 | } 147 | }, 148 | ); 149 | } 150 | }); 151 | } 152 | 153 | // Quick and dirty way to provide a sortable key for reviewers. 154 | issue.reviewersNames = ''; 155 | if (issue.reviewers.length) { 156 | issue.reviewersNames = issue.reviewers 157 | .map((review) => review.author.login) 158 | .join('-'); 159 | } 160 | } 161 | 162 | export function setStateLabels(issue) { 163 | const labels = (issue.labels && issue.labels.nodes) || []; 164 | // Define current state of the issue. 165 | issue.stateLabel = issue.state.toLowerCase(); 166 | issue.stateLabelColor = 167 | issue.state === 'CLOSED' ? colors.closed : colors.open; 168 | 169 | if (issue.state === 'OPEN' && hasLabel(labels, 'state: pull request ready')) { 170 | issue.stateLabel = 'PR ready'; 171 | issue.stateLabelColor = colors.prReady; 172 | } else if (issue.state === 'OPEN' && hasLabel(labels, 'state: in progress')) { 173 | issue.stateLabel = 'in progress'; 174 | issue.stateLabelColor = colors.inProgress; 175 | } else if ( 176 | issue.state === 'CLOSED' && 177 | hasLabel(labels, 'state: verified fixed') 178 | ) { 179 | issue.stateLabel = 'verified fixed'; 180 | issue.stateLabelColor = colors.verified; 181 | } else if (issue.state === 'CLOSED' && hasLabel(labels, 'qa: not needed')) { 182 | issue.stateLabel = 'closed QA-'; 183 | issue.stateLabelColor = colors.verified; 184 | } 185 | 186 | issue.stateLabelTextColor = colourIsLight(issue.stateLabelColor) 187 | ? '#000' 188 | : '#fff'; 189 | } 190 | 191 | /* 192 | * This function massages the issue data and adds additional properties 193 | * to make it easier to display. 194 | */ 195 | export function formatIssueData(jsonData) { 196 | const issues = []; 197 | 198 | if (jsonData.data && jsonData.data.milestone_issues) { 199 | const issueData = jsonData.data.milestone_issues.results; 200 | 201 | issueData.forEach((item) => { 202 | // Set defaults. 203 | const { issue } = item; 204 | setIssuePriorityProp(issue); 205 | setRepoProp(issue); 206 | setProjectProps(issue); 207 | setStateLabels(issue); 208 | setAssigneeProp(issue); 209 | setReviewerDetails(issue); 210 | issues.push(issue); 211 | }); 212 | } 213 | 214 | return issues; 215 | } 216 | -------------------------------------------------------------------------------- /lib/utils/projects.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Defaulting to starting with today's date work out the current year and quarter. 3 | * 4 | */ 5 | export function getCurrentQuarter({ _date } = {}) { 6 | const today = _date || new Date(); 7 | const year = today.getFullYear(); 8 | const quarter = `Q${Math.floor((today.getMonth() + 3) / 3)}`; 9 | return { 10 | year, 11 | quarter, 12 | }; 13 | } 14 | 15 | /* 16 | * Get the previous quarter and year, from starting with a quarter and year as input. 17 | * 18 | */ 19 | export function getPrevQuarter({ quarter, year } = {}) { 20 | if (!quarter || !year) { 21 | return {}; 22 | } 23 | 24 | const numericQuarter = quarter.substr(1); 25 | let newQuarter = parseInt(numericQuarter, 10); 26 | let newYear = parseInt(year, 10); 27 | 28 | if (newQuarter > 1) { 29 | newQuarter = newQuarter - 1; 30 | } else if (newQuarter === 1) { 31 | newQuarter = 4; 32 | newYear = newYear - 1; 33 | } 34 | 35 | return { 36 | year: newYear, 37 | quarter: `Q${newQuarter}`, 38 | }; 39 | } 40 | 41 | /* 42 | * Get the next quarter and year, starting with a quarter and year as input. 43 | * 44 | */ 45 | export function getNextQuarter({ quarter, year } = {}) { 46 | if (!quarter || !year) { 47 | return {}; 48 | } 49 | 50 | const numericQuarter = quarter.substr(1); 51 | let newYear = parseInt(year, 10); 52 | let newQuarter = parseInt(numericQuarter, 10); 53 | 54 | if (newQuarter < 4) { 55 | newQuarter = newQuarter + 1; 56 | } else if (newQuarter === 4) { 57 | newQuarter = 1; 58 | newYear = newYear + 1; 59 | } 60 | 61 | return { 62 | year: newYear, 63 | quarter: `Q${newQuarter}`, 64 | }; 65 | } 66 | 67 | /* 68 | * This is a universal wrapper for DOMParser 69 | */ 70 | export function getDOMParser() { 71 | if (typeof window === 'undefined' && require) { 72 | // eslint-disable-next-line global-require 73 | const { JSDOM } = require('jsdom'); 74 | const { DOMParser } = new JSDOM().window; 75 | return DOMParser; 76 | } 77 | // eslint-disable-next-line no-undef 78 | return window.DOMParser; 79 | } 80 | 81 | /* 82 | * This function parses the specially formatted HTML we add to projects to 83 | * provide additional metadata about the projects. 84 | * This is mostly to workaround the lack of features like labels on gh projects. 85 | */ 86 | export function parseProjectMeta(HTML) { 87 | const DParser = getDOMParser(); 88 | const parser = new DParser(); 89 | const doc = parser.parseFromString(HTML, 'text/html'); 90 | const engineers = doc 91 | .evaluate( 92 | "//details//dl/dt[contains(., 'Engineering')]/following-sibling::dd[1]", 93 | doc, 94 | null, 95 | 2, 96 | null, 97 | ) 98 | .stringValue.replace(/ ?@/g, '') 99 | .split(','); 100 | const goalType = doc 101 | .evaluate( 102 | "//details//dl/dt[contains(., 'Goal Type')]/following-sibling::dd[1]", 103 | doc, 104 | null, 105 | 2, 106 | null, 107 | ) 108 | .stringValue.toLowerCase(); 109 | const size = doc.evaluate( 110 | "//details//dl/dt[contains(., 'Size')]/following-sibling::dd[1]", 111 | doc, 112 | null, 113 | 2, 114 | null, 115 | ).stringValue; 116 | const details = doc.querySelector('details'); 117 | if (details) { 118 | // Remove the meta data HTML from the doc. 119 | details.parentNode.removeChild(details); 120 | } 121 | return [{ engineers, goalType, size }, doc.documentElement.outerHTML]; 122 | } 123 | -------------------------------------------------------------------------------- /lib/utils/sort.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | export function dateSort(key) { 3 | return (a, b) => { 4 | return new Date(a[key]) - new Date(b[key]); 5 | }; 6 | } 7 | 8 | export function numericSort(key) { 9 | return (a, b) => { 10 | return a[key] - b[key]; 11 | }; 12 | } 13 | 14 | export function alphaSort(key) { 15 | return (a, b) => { 16 | const strA = a[key].toUpperCase(); 17 | const strB = b[key].toUpperCase(); 18 | if (strA < strB) { 19 | return -1; 20 | } 21 | if (strA > strB) { 22 | return 1; 23 | } 24 | // names must be equal 25 | return 0; 26 | }; 27 | } 28 | 29 | export function sortData({ columnKey, data, direction, sortConfig } = {}) { 30 | if (!data) { 31 | console.debug('No data yet, bailing'); 32 | return data; 33 | } 34 | if (!Object.keys(sortConfig).includes(columnKey)) { 35 | console.debug( 36 | `"${columnKey}" does not match one of "${Object.keys(sortConfig).join( 37 | ', ', 38 | )}"`, 39 | ); 40 | return data; 41 | } 42 | if (!['desc', 'asc'].includes(direction)) { 43 | console.debug(`"${direction}" does not match one of 'asc' or 'desc'`); 44 | return data; 45 | } 46 | 47 | const sortFunc = sortConfig[columnKey].sortFunc || alphaSort; 48 | const sorted = [].concat(data).sort(sortFunc(columnKey)); 49 | 50 | // Reverse for desc. 51 | if (direction === 'desc') { 52 | sorted.reverse(); 53 | } 54 | return sorted; 55 | } 56 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingSlash: true, 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start", 7 | "test": "jest . --watch", 8 | "test-ci": "jest . --coverage --maxWorkers=2", 9 | "test-coverage": "jest . --coverage --watch", 10 | "lint": "eslint .", 11 | "prettier": "prettier --write '**'", 12 | "prettier-ci": "prettier -c '**'", 13 | "stylelint": "stylelint **/*.scss" 14 | }, 15 | "dependencies": { 16 | "@primer/octicons-react": "18.3.0", 17 | "apollo-boost": "0.4.9", 18 | "bootstrap": "5.2.3", 19 | "classnames": "2.3.2", 20 | "common-tags": "1.8.2", 21 | "dompurify": "3.0.1", 22 | "graphql": "16.6.0", 23 | "graphql-tag": "2.12.6", 24 | "isomorphic-fetch": "3.0.0", 25 | "jsdom": "21.1.1", 26 | "next": "13.0.2", 27 | "node-cache": "5.1.2", 28 | "nprogress": "0.2.0", 29 | "object-hash": "3.0.0", 30 | "query-string": "7.1.3", 31 | "react": "18.2.0", 32 | "react-bootstrap": "2.7.2", 33 | "react-dom": "18.2.0", 34 | "react-helmet": "6.1.0", 35 | "react-timeago": "7.1.0", 36 | "sass": "1.61.0", 37 | "swr": "1.3.0" 38 | }, 39 | "devDependencies": { 40 | "@babel/core": "7.21.4", 41 | "@babel/eslint-parser": "7.21.3", 42 | "@testing-library/jest-dom": "5.16.5", 43 | "@testing-library/react": "14.0.0", 44 | "babel-jest": "29.5.0", 45 | "eslint": "8.38.0", 46 | "eslint-config-amo": "5.7.0", 47 | "eslint-config-next": "13.2.4", 48 | "eslint-plugin-amo": "1.21.0", 49 | "fetch-mock": "9.11.0", 50 | "jest": "29.5.0", 51 | "jest-environment-jsdom": "29.5.0", 52 | "mock-express-request": "0.2.2", 53 | "mock-express-response": "0.3.0", 54 | "next-router-mock": "0.9.3", 55 | "prettier": "2.8.7", 56 | "stylelint": "14.16.1", 57 | "stylelint-config-prettier-scss": "0.0.1", 58 | "stylelint-config-standard": "29.0.0", 59 | "stylelint-config-standard-scss": "6.1.0", 60 | "stylelint-config-suitcss": "18.0.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import 'bootstrap/dist/css/bootstrap.min.css'; 2 | import '../styles/globals.scss'; 3 | import 'nprogress/nprogress.css'; 4 | 5 | import { Nav, Navbar } from 'react-bootstrap'; 6 | import { MarkGithubIcon } from '@primer/octicons-react'; 7 | import React from 'react'; 8 | import { Helmet } from 'react-helmet'; 9 | import NProgress from 'nprogress'; 10 | import { useRouter } from 'next/router'; 11 | import Link from 'next/link'; 12 | 13 | function MyApp({ Component, pageProps }) { 14 | const router = useRouter(); 15 | React.useEffect(() => { 16 | const routeChangeStart = () => { 17 | NProgress.start(); 18 | }; 19 | const routeChangeComplete = () => { 20 | NProgress.done(); 21 | }; 22 | 23 | router.events.on('routeChangeStart', routeChangeStart); 24 | router.events.on('routeChangeComplete', routeChangeComplete); 25 | router.events.on('routeChangeError', routeChangeComplete); 26 | return () => { 27 | router.events.off('routeChangeStart', routeChangeStart); 28 | router.events.off('routeChangeComplete', routeChangeComplete); 29 | router.events.off('routeChangeError', routeChangeComplete); 30 | }; 31 | }); 32 | 33 | return ( 34 |
35 | 36 | 37 | 79 | 96 | 97 | 98 |
99 | ); 100 | } 101 | 102 | export default MyApp; 103 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | export default class MyDocument extends Document { 5 | static async getInitialProps(...args) { 6 | const documentProps = await super.getInitialProps(...args); 7 | // see https://github.com/nfl/react-helmet#server-usage for more information 8 | // 'head' was occupied by 'renderPage().head', we cannot use it 9 | return { ...documentProps, helmet: Helmet.renderStatic() }; 10 | } 11 | 12 | // should render on 13 | get helmetHtmlAttrComponents() { 14 | return this.props.helmet.htmlAttributes.toComponent(); 15 | } 16 | 17 | // should render on 18 | get helmetBodyAttrComponents() { 19 | return this.props.helmet.bodyAttributes.toComponent(); 20 | } 21 | 22 | // should render on 23 | get helmetHeadComponents() { 24 | return Object.keys(this.props.helmet) 25 | .filter((el) => el !== 'htmlAttributes' && el !== 'bodyAttributes') 26 | .map((el) => this.props.helmet[el].toComponent()); 27 | } 28 | 29 | render() { 30 | return ( 31 | 32 | {this.helmetHeadComponents} 33 | 34 |
35 | 36 | 37 | 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/api/bz-issue-counts.js: -------------------------------------------------------------------------------- 1 | import { fetchIssueCount } from 'lib/bzapi'; 2 | import { bugzilla } from 'lib/const'; 3 | 4 | export default async (req, res) => { 5 | const requests = []; 6 | const combinedData = {}; 7 | 8 | for (const product of bugzilla.products) { 9 | combinedData[product] = {}; 10 | 11 | for (const priority of bugzilla.priorities) { 12 | requests.push( 13 | fetchIssueCount({ 14 | product, 15 | priority, 16 | bug_severity: null, 17 | }).then((result) => { 18 | let priorityLabel; 19 | switch (priority) { 20 | case '--': 21 | priorityLabel = 'default'; 22 | break; 23 | default: 24 | priorityLabel = priority.toLowerCase(); 25 | } 26 | combinedData[product][`priority-${priorityLabel}`] = result; 27 | }), 28 | ); 29 | } 30 | 31 | for (const bug_severity of bugzilla.severities) { 32 | requests.push( 33 | fetchIssueCount({ 34 | product, 35 | bug_severity, 36 | priority: null, 37 | }).then((result) => { 38 | let severityLabel; 39 | switch (bug_severity) { 40 | case 'N/A': 41 | severityLabel = 'not-applicable'; 42 | break; 43 | case '--': 44 | severityLabel = 'default'; 45 | break; 46 | default: 47 | severityLabel = bug_severity.toLowerCase(); 48 | } 49 | combinedData[product][`severity-${severityLabel}`] = result; 50 | }), 51 | ); 52 | } 53 | 54 | requests.push( 55 | fetchIssueCount({ 56 | product, 57 | bug_severity: null, 58 | priority: null, 59 | }).then((result) => { 60 | combinedData[product].total = result; 61 | }), 62 | ); 63 | } 64 | 65 | return Promise.all(requests).then(() => { 66 | res.json(combinedData); 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /pages/api/bz-need-infos.js: -------------------------------------------------------------------------------- 1 | import { fetchNeedInfo, baseWEBURL } from 'lib/bzapi'; 2 | 3 | export default async (req, res) => { 4 | const requests = []; 5 | const combinedData = {}; 6 | const BZ_USERS = JSON.parse(process.env.BZ_USERS) || {}; 7 | 8 | for (const nick in BZ_USERS) { 9 | if (Object.prototype.hasOwnProperty.call(BZ_USERS, nick)) { 10 | combinedData[nick] = {}; 11 | requests.push( 12 | fetchNeedInfo(BZ_USERS[nick]).then((result) => { 13 | combinedData[nick].count = result.bugs.length; 14 | if (result.bugs.length) { 15 | combinedData[nick].url = `${baseWEBURL}?bug_id=${result.bugs 16 | .map((item) => item.id) 17 | .join(',')}`; 18 | } 19 | }), 20 | ); 21 | } 22 | } 23 | 24 | return Promise.all(requests).then(() => { 25 | res.json(combinedData); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /pages/api/bz-whiteboard-tags.js: -------------------------------------------------------------------------------- 1 | import { fetchWhiteboardTag } from 'lib/bzapi'; 2 | import { bugzilla } from 'lib/const'; 3 | 4 | export default async (req, res) => { 5 | const requests = []; 6 | const combinedData = {}; 7 | 8 | for (const whiteboardTag of bugzilla.whiteboardTags) { 9 | combinedData[whiteboardTag] = {}; 10 | requests.push( 11 | fetchWhiteboardTag(whiteboardTag).then((result) => { 12 | combinedData[whiteboardTag] = result; 13 | }), 14 | ); 15 | } 16 | 17 | return Promise.all(requests).then(() => { 18 | res.json(combinedData); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /pages/api/gh-contrib-welcome.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import createClient from 'lib/ghapi'; 3 | import { contribRepos } from 'lib/const'; 4 | import { oneLine } from 'common-tags'; 5 | 6 | const query = gql` 7 | query getContribWelcome($query: String!) { 8 | contrib_welcome: search(type: ISSUE, query: $query, first: 100) { 9 | issueCount 10 | results: edges { 11 | issue: node { 12 | ... on Issue { 13 | number 14 | updatedAt 15 | title 16 | url 17 | repository { 18 | name 19 | } 20 | labels(first: 100) { 21 | nodes { 22 | name 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | `; 31 | 32 | export default async (req, res) => { 33 | const client = createClient(); 34 | const variables = { 35 | query: oneLine`${contribRepos.map((n) => `repo:${n}`).join('\n')} 36 | label:"contrib:welcome" 37 | is:open 38 | sort:updated-desc 39 | type:issues`, 40 | }; 41 | const data = await client.query({ 42 | query, 43 | variables, 44 | }); 45 | res.json(data); 46 | }; 47 | -------------------------------------------------------------------------------- /pages/api/gh-good-first-bugs.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import createClient from 'lib/ghapi'; 3 | import { contribRepos } from 'lib/const'; 4 | import { oneLine } from 'common-tags'; 5 | 6 | const query = gql` 7 | query getGoodFirstBugs($query: String!) { 8 | good_first_bugs: search(type: ISSUE, query: $query, first: 100) { 9 | issueCount 10 | results: edges { 11 | issue: node { 12 | ... on Issue { 13 | number 14 | updatedAt 15 | title 16 | url 17 | repository { 18 | name 19 | } 20 | labels(first: 100) { 21 | nodes { 22 | name 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | `; 31 | 32 | export default async (req, res) => { 33 | const client = createClient(); 34 | const variables = { 35 | query: oneLine`${contribRepos.map((n) => `repo:${n}`).join('\n')} 36 | label:"contrib:good_first_bug" 37 | is:open 38 | sort:updated-desc 39 | type:issues`, 40 | }; 41 | const data = await client.query({ 42 | query, 43 | variables, 44 | }); 45 | res.json(data); 46 | }; 47 | -------------------------------------------------------------------------------- /pages/api/gh-issue-counts.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import createClient from 'lib/ghapi'; 3 | 4 | const query = gql` 5 | fragment issueCounts on Repository { 6 | description 7 | total_issues: issues(states: OPEN) { 8 | totalCount 9 | } 10 | triaged: issues( 11 | states: OPEN 12 | labels: [ 13 | "priority:p1" 14 | "priority:p2" 15 | "priority:p3" 16 | "priority:p4" 17 | "priority:p5" 18 | ] 19 | ) { 20 | totalCount 21 | } 22 | open_prod_bugs: issues(states: OPEN, labels: "type:prod_bug") { 23 | totalCount 24 | } 25 | open_p1s: issues(states: OPEN, labels: "priority:p1") { 26 | totalCount 27 | } 28 | open_p2s: issues(states: OPEN, labels: "priority:p2") { 29 | totalCount 30 | } 31 | open_p3s: issues(states: OPEN, labels: "priority:p3") { 32 | totalCount 33 | } 34 | open_prs: pullRequests(states: OPEN) { 35 | totalCount 36 | } 37 | } 38 | 39 | { 40 | addons: repository(name: "addons", owner: "mozilla") { 41 | ...issueCounts 42 | } 43 | addons_server: repository(name: "addons-server", owner: "mozilla") { 44 | ...issueCounts 45 | } 46 | addons_frontend: repository(name: "addons-frontend", owner: "mozilla") { 47 | ...issueCounts 48 | } 49 | addons_blog: repository(name: "addons-blog", owner: "mozilla") { 50 | ...issueCounts 51 | } 52 | addons_linter: repository(name: "addons-linter", owner: "mozilla") { 53 | ...issueCounts 54 | } 55 | addons_code_manager: repository( 56 | name: "addons-code-manager" 57 | owner: "mozilla" 58 | ) { 59 | ...issueCounts 60 | } 61 | } 62 | `; 63 | 64 | export default async (req, res) => { 65 | const client = createClient(); 66 | const data = await client.query({ 67 | query, 68 | }); 69 | res.json(data); 70 | }; 71 | -------------------------------------------------------------------------------- /pages/api/gh-maybe-good-first-bugs.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import createClient from 'lib/ghapi'; 3 | import { contribRepos } from 'lib/const'; 4 | import { oneLine } from 'common-tags'; 5 | 6 | const query = gql` 7 | query getMaybeGoodFirstBugs($query: String!) { 8 | maybe_good_first_bugs: search(type: ISSUE, query: $query, first: 100) { 9 | issueCount 10 | results: edges { 11 | issue: node { 12 | ... on Issue { 13 | number 14 | updatedAt 15 | title 16 | url 17 | repository { 18 | name 19 | } 20 | labels(first: 100) { 21 | nodes { 22 | name 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | `; 31 | 32 | export default async (req, res) => { 33 | const client = createClient(); 34 | const variables = { 35 | query: oneLine`${contribRepos.map((n) => `repo:${n}`).join('\n')} 36 | label:"contrib:maybe_good_first_bug" 37 | is:open 38 | sort:updated-desc 39 | type:issues`, 40 | }; 41 | const data = await client.query({ 42 | query, 43 | variables, 44 | }); 45 | res.json(data); 46 | }; 47 | -------------------------------------------------------------------------------- /pages/api/gh-milestone-issues.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import createClient from 'lib/ghapi'; 3 | import { validMilestoneRX } from 'lib/const'; 4 | import { oneLine } from 'common-tags'; 5 | 6 | const query = gql` 7 | query getMilestoneIssue($query: String!) { 8 | milestone_issues: search(type: ISSUE, query: $query, first: 100) { 9 | issueCount 10 | results: edges { 11 | issue: node { 12 | ... on Issue { 13 | state 14 | number 15 | updatedAt 16 | title 17 | url 18 | repository { 19 | name 20 | } 21 | assignees(first: 10) { 22 | nodes { 23 | id 24 | name 25 | login 26 | avatarUrl 27 | } 28 | } 29 | labels(first: 100) { 30 | nodes { 31 | name 32 | } 33 | } 34 | projectCards(first: 100) { 35 | nodes { 36 | project { 37 | name 38 | url 39 | } 40 | } 41 | } 42 | timelineItems(last: 20, itemTypes: CROSS_REFERENCED_EVENT) { 43 | edges { 44 | event: node { 45 | ... on CrossReferencedEvent { 46 | source { 47 | ... on PullRequest { 48 | bodyText 49 | permalink 50 | reviews(last: 10, states: APPROVED) { 51 | totalCount 52 | edges { 53 | review: node { 54 | author { 55 | login 56 | avatarUrl 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | `; 73 | 74 | export default async (req, res) => { 75 | const client = createClient(); 76 | 77 | let { milestone } = req.query; 78 | // Next.js requires us to use `-` in urls instead of `.` due to 79 | // https://github.com/vercel/next.js/issues/16617 80 | 81 | if (!validMilestoneRX.test(milestone)) { 82 | res.status(400).json({ error: 'Incorrect milestone format' }); 83 | } else { 84 | milestone = milestone.replace(/-/g, '.'); 85 | const variables = { 86 | query: oneLine`repo:mozilla/addons 87 | repo:mozilla/addons-server 88 | repo:mozilla/addons-frontend 89 | repo:mozilla/addons-blog 90 | repo:mozilla/addons-linter 91 | repo:mozilla/addons-code-manager 92 | milestone:${milestone} 93 | type:issues`, 94 | }; 95 | const data = await client.query({ 96 | query, 97 | variables, 98 | }); 99 | res.json(data); 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /pages/api/gh-projects.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import createClient from 'lib/ghapi'; 3 | import { validYearRX, validQuarterRX } from 'lib/const'; 4 | 5 | const query = gql` 6 | query getProjects($projectSearch: String!) { 7 | organization(login: "mozilla") { 8 | projects(first: 100, search: $projectSearch) { 9 | nodes { 10 | name 11 | bodyHTML 12 | state 13 | url 14 | updatedAt 15 | columns(first: 10) { 16 | edges { 17 | node { 18 | id 19 | name 20 | cards(first: 100, archivedStates: [NOT_ARCHIVED]) { 21 | totalCount 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | `; 31 | 32 | export default async (req, res) => { 33 | const client = createClient(); 34 | const { year, quarter } = req.query; 35 | 36 | if (!validYearRX.test(year)) { 37 | res.status(400).json({ error: 'Incorrect year format' }); 38 | } else if (!validQuarterRX.test(quarter)) { 39 | res.status(400).json({ error: 'Incorrect quarter format' }); 40 | } else { 41 | const projects = await client.query({ 42 | query, 43 | variables: { 44 | projectSearch: `Add-ons ${quarter} ${year}`, 45 | }, 46 | }); 47 | res.json(projects); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /pages/api/gh-team.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import createClient from 'lib/ghapi'; 3 | import { validAMOProjectTeamMembers } from 'lib/const'; 4 | 5 | // addons-robot is outside the org/teams, so we have to cheat to return the 6 | // list of team members. 7 | 8 | const query = gql` 9 | query getTeam($userSearch: String!) { 10 | search(query: $userSearch, type: USER, first: 10) { 11 | edges { 12 | node { 13 | ... on User { 14 | id 15 | avatarUrl 16 | login 17 | name 18 | } 19 | } 20 | } 21 | } 22 | } 23 | `; 24 | 25 | export default async (req, res) => { 26 | const client = createClient(); 27 | const team = await client.query({ 28 | query, 29 | variables: { 30 | userSearch: validAMOProjectTeamMembers.map((x) => `user:${x}`).join(' '), 31 | }, 32 | }); 33 | res.json(team); 34 | }; 35 | -------------------------------------------------------------------------------- /pages/contrib/contrib-welcome.js: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import Contrib from 'components/Contrib'; 3 | import Error from 'next/error'; 4 | import { formatContribData } from 'lib/utils/contrib'; 5 | import { getApiURL } from 'lib/utils'; 6 | 7 | const contribWelcomeURL = getApiURL('/api/gh-contrib-welcome/'); 8 | 9 | export async function getServerSideProps() { 10 | const res = await fetch(contribWelcomeURL); 11 | const errorCode = res.ok ? false : res.status; 12 | const contribWelcomeData = await res.json(); 13 | return { 14 | props: { 15 | errorCode, 16 | contribWelcomeData, 17 | }, 18 | }; 19 | } 20 | 21 | const ContribWelcome = (props) => { 22 | if (props.errorCode) { 23 | return ; 24 | } 25 | 26 | const { contribWelcomeData: initialContribWelcomeData } = props; 27 | const { data: contribData } = useSWR( 28 | contribWelcomeURL, 29 | async () => { 30 | const result = await fetch(contribWelcomeURL); 31 | const json = await result.json(); 32 | return json; 33 | }, 34 | { fallbackData: initialContribWelcomeData, refreshInterval: 30000 }, 35 | ); 36 | 37 | return ( 38 | 41 | ); 42 | }; 43 | 44 | export default ContribWelcome; 45 | -------------------------------------------------------------------------------- /pages/contrib/good-first-bugs.js: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import Error from 'next/error'; 3 | import Contrib from 'components/Contrib'; 4 | import { formatContribData } from 'lib/utils/contrib'; 5 | import { getApiURL } from 'lib/utils'; 6 | 7 | const goodFirstBugsURL = getApiURL('/api/gh-good-first-bugs/'); 8 | 9 | export async function getServerSideProps() { 10 | const res = await fetch(goodFirstBugsURL); 11 | const errorCode = res.ok ? false : res.status; 12 | const goodFirstBugsData = await res.json(); 13 | 14 | return { 15 | props: { 16 | errorCode, 17 | goodFirstBugsData, 18 | }, 19 | }; 20 | } 21 | 22 | const GoodFirstBugs = (props) => { 23 | if (props.errorCode) { 24 | return ; 25 | } 26 | 27 | const { goodFirstBugsData: initialGoodFirstBugsData } = props; 28 | const { data: goodFirstBugsData } = useSWR( 29 | goodFirstBugsURL, 30 | async () => { 31 | const result = await fetch(goodFirstBugsURL); 32 | const json = await result.json(); 33 | return json; 34 | }, 35 | { fallbackData: initialGoodFirstBugsData, refreshInterval: 30000 }, 36 | ); 37 | 38 | return ( 39 | 45 | ); 46 | }; 47 | 48 | export default GoodFirstBugs; 49 | -------------------------------------------------------------------------------- /pages/contrib/maybe-good-first-bugs.js: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import Error from 'next/error'; 3 | import Contrib from 'components/Contrib'; 4 | import { formatContribData } from 'lib/utils/contrib'; 5 | import { getApiURL } from 'lib/utils'; 6 | 7 | const maybeGoodFirstBugsURL = getApiURL('/api/gh-maybe-good-first-bugs/'); 8 | 9 | export async function getServerSideProps() { 10 | const res = await fetch(maybeGoodFirstBugsURL); 11 | const errorCode = res.ok ? false : res.status; 12 | const maybeGoodFirstBugsData = await res.json(); 13 | 14 | return { 15 | props: { 16 | errorCode, 17 | maybeGoodFirstBugsData, 18 | }, 19 | }; 20 | } 21 | 22 | const MaybeGoodFirstBugs = (props) => { 23 | if (props.errorCode) { 24 | return ; 25 | } 26 | 27 | const { maybeGoodFirstBugsData: initialMaybeGoodFirstBugsData } = props; 28 | const { data: maybeGoodFirstBugsData } = useSWR( 29 | maybeGoodFirstBugsURL, 30 | async () => { 31 | const result = await fetch(maybeGoodFirstBugsURL); 32 | const json = await result.json(); 33 | return json; 34 | }, 35 | { fallbackData: initialMaybeGoodFirstBugsData, refreshInterval: 30000 }, 36 | ); 37 | 38 | return ( 39 | 45 | ); 46 | }; 47 | 48 | export default MaybeGoodFirstBugs; 49 | -------------------------------------------------------------------------------- /pages/dashboards/amo.js: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | import Error from 'next/error'; 3 | import { Container } from 'react-bootstrap'; 4 | import useSWR from 'swr'; 5 | import AMODashCountGroup from 'components/AMODashCountGroup'; 6 | import { getApiURL } from 'lib/utils'; 7 | 8 | function renderCounts(issueCountData) { 9 | const countGroups = []; 10 | Object.keys(issueCountData).forEach((repo) => { 11 | countGroups.push( 12 | , 18 | ); 19 | }); 20 | return countGroups; 21 | } 22 | 23 | const githubIssueCountsURL = getApiURL('/api/gh-issue-counts/'); 24 | 25 | export async function getServerSideProps() { 26 | const res = await fetch(githubIssueCountsURL); 27 | const errorCode = res.ok ? false : res.status; 28 | const amoDashData = await res.json(); 29 | 30 | return { 31 | props: { 32 | errorCode, 33 | issueCounts: amoDashData, 34 | }, 35 | }; 36 | } 37 | 38 | const DashboardAMO = (props) => { 39 | if (props.errorCode) { 40 | return ; 41 | } 42 | 43 | const { data, error } = useSWR( 44 | githubIssueCountsURL, 45 | async () => { 46 | const result = await fetch(githubIssueCountsURL); 47 | return result.json(); 48 | }, 49 | { refreshInterval: 30000, fallbackData: props.issueCounts }, 50 | ); 51 | 52 | const isLoading = !error && !data; 53 | // const isError = error; 54 | 55 | return ( 56 |
57 | 58 | AMO Dashboard 59 | 60 | 61 | 62 |
63 | {isLoading ? ( 64 |
65 |

Loading...

66 |
67 | ) : ( 68 | renderCounts(data.data) 69 | )} 70 |
71 |
72 |
73 | ); 74 | }; 75 | 76 | export default DashboardAMO; 77 | -------------------------------------------------------------------------------- /pages/dashboards/webext.js: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | import { Container } from 'react-bootstrap'; 3 | import Error from 'next/error'; 4 | import useSWR from 'swr'; 5 | import DashCount from 'components/DashCount'; 6 | import DashBlank from 'components/DashBlank'; 7 | import DashCountGroup from 'components/DashCountGroup'; 8 | import { getApiURL } from 'lib/utils'; 9 | 10 | const meta = { 11 | Toolkit: { 12 | title: 'Addons Manager Bugs', 13 | description: 'Addons Manager related code', 14 | }, 15 | WebExtensions: { 16 | title: 'Web Extensions Bugs', 17 | description: 'Browser APIs for Webextensions', 18 | }, 19 | }; 20 | 21 | const issueCountURL = getApiURL('/api/bz-issue-counts/'); 22 | const needInfoURL = getApiURL('/api/bz-need-infos/'); 23 | const whiteboardURL = getApiURL('/api/bz-whiteboard-tags/'); 24 | 25 | export async function getServerSideProps() { 26 | const [issueCountsResponse, needInfosResponse, whiteboardResponse] = 27 | await Promise.all([ 28 | fetch(issueCountURL), 29 | fetch(needInfoURL), 30 | fetch(whiteboardURL), 31 | ]); 32 | 33 | const errorCode = 34 | issueCountsResponse.ok && needInfosResponse.ok && whiteboardResponse.ok 35 | ? false 36 | : 500; 37 | 38 | const [issueCounts, needInfos, whiteboardTags] = await Promise.all([ 39 | issueCountsResponse.json(), 40 | needInfosResponse.json(), 41 | whiteboardResponse.json(), 42 | ]); 43 | 44 | return { 45 | props: { 46 | errorCode, 47 | issueCounts, 48 | needInfos, 49 | whiteboardTags, 50 | }, 51 | }; 52 | } 53 | 54 | function DashboardWE(props) { 55 | if (props.errorCode) { 56 | return ; 57 | } 58 | 59 | function getIssueCounts() { 60 | const { data, error } = useSWR( 61 | issueCountURL, 62 | async () => { 63 | const result = await fetch(issueCountURL); 64 | return result.json(); 65 | }, 66 | { refreshInterval: 30000, fallbackData: props.issueCounts }, 67 | ); 68 | return { 69 | data, 70 | isLoading: !error && !data, 71 | isError: error, 72 | }; 73 | } 74 | 75 | function getNeedInfos() { 76 | const { data, error } = useSWR( 77 | needInfoURL, 78 | async () => { 79 | const result = await fetch(needInfoURL); 80 | return result.json(); 81 | }, 82 | { refreshInterval: 45000, fallbackData: props.needInfos }, 83 | ); 84 | return { 85 | data, 86 | isLoading: !error && !data, 87 | isError: error, 88 | }; 89 | } 90 | 91 | function getWhiteboardTags() { 92 | const { data, error } = useSWR( 93 | whiteboardURL, 94 | async () => { 95 | const result = await fetch(whiteboardURL); 96 | return result.json(); 97 | }, 98 | { refreshInterval: 45000, fallbackData: props.whiteboardTags }, 99 | ); 100 | return { 101 | data, 102 | isLoading: !error && !data, 103 | isError: error, 104 | }; 105 | } 106 | 107 | const needInfos = getNeedInfos(); 108 | const issueCounts = getIssueCounts(); 109 | const whiteboardTags = getWhiteboardTags(); 110 | 111 | function renderChild({ data, dataKey, component, title, warningLimit }) { 112 | const { count, url } = data[dataKey]; 113 | return ( 114 | 121 | ); 122 | } 123 | 124 | function renderChildren(component, data) { 125 | return [ 126 | renderChild({ 127 | data, 128 | dataKey: 'severity-default', 129 | title: 'Untriaged', 130 | warningLimit: 15, 131 | component, 132 | }), 133 | renderChild({ 134 | data, 135 | dataKey: 'severity-s1', 136 | title: 'S1', 137 | warningLimit: 1, 138 | component, 139 | }), 140 | renderChild({ 141 | data, 142 | dataKey: 'severity-s2', 143 | title: 'S2', 144 | warningLimit: 10, 145 | component, 146 | }), 147 | renderChild({ 148 | data, 149 | dataKey: 'priority-p1', 150 | title: 'P1', 151 | warningLimit: 10, 152 | component, 153 | }), 154 | renderChild({ 155 | data, 156 | dataKey: 'priority-p2', 157 | title: 'P2', 158 | warningLimit: 20, 159 | component, 160 | }), 161 | , 162 | , 163 | ]; 164 | } 165 | 166 | function renderCounts() { 167 | if (!issueCounts.data) { 168 | return null; 169 | } 170 | const countGroups = []; 171 | Object.keys(issueCounts.data).forEach((component, index) => { 172 | if (Object.prototype.hasOwnProperty.call(meta, component)) { 173 | countGroups.push( 174 | DashCountGroup({ 175 | key: index + meta[component].title, 176 | children: renderChildren(component, issueCounts.data[component]), 177 | title: meta[component].title, 178 | description: meta[component].description, 179 | }), 180 | ); 181 | } else { 182 | // eslint-disable-next-line no-console 183 | console.debug(`countGroup "${component}: added without meta`); 184 | } 185 | }); 186 | return countGroups; 187 | } 188 | 189 | function renderNeedInfos() { 190 | const children = []; 191 | Object.keys(needInfos.data).forEach((nick) => { 192 | children.push( 193 | renderChild({ 194 | data: needInfos.data, 195 | dataKey: nick, 196 | component: nick, 197 | title: nick, 198 | warningLimit: 5, 199 | }), 200 | ); 201 | }); 202 | 203 | children.push(); 204 | children.push(); 205 | 206 | return DashCountGroup({ 207 | className: 'needinfos', 208 | key: 'needinfos', 209 | children, 210 | title: 'Need Infos', 211 | description: 'Count of need info requests', 212 | }); 213 | } 214 | 215 | function renderWhiteBoardTags() { 216 | const children = []; 217 | Object.keys(whiteboardTags.data).forEach((tag) => { 218 | children.push( 219 | renderChild({ 220 | data: whiteboardTags.data, 221 | dataKey: tag, 222 | component: tag, 223 | title: tag, 224 | }), 225 | ); 226 | }); 227 | 228 | return DashCountGroup({ 229 | className: 'whiteboardtags', 230 | key: 'whiteboardtags', 231 | children, 232 | title: 'Whiteboard Tags', 233 | description: 'Whiteboard Tags to track', 234 | }); 235 | } 236 | 237 | let isLoading = false; 238 | if ( 239 | needInfos.isLoading || 240 | issueCounts.isLoading || 241 | whiteboardTags.isLoading 242 | ) { 243 | isLoading = true; 244 | } 245 | 246 | return ( 247 |
248 | 249 | Webextension Dashboard 250 | 251 | 252 | 253 |
254 | {isLoading ? ( 255 |
Loading...
256 | ) : ( 257 | [renderWhiteBoardTags(), ...renderCounts(), renderNeedInfos()] 258 | )} 259 |
260 |
261 |
262 | ); 263 | } 264 | 265 | export default DashboardWE; 266 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import { Button, Card, Col, Container, Row } from 'react-bootstrap'; 2 | import { 3 | MeterIcon, 4 | MilestoneIcon, 5 | PeopleIcon, 6 | ProjectIcon, 7 | } from '@primer/octicons-react'; 8 | import { Helmet } from 'react-helmet'; 9 | import Link from 'next/link'; 10 | 11 | export default function Home() { 12 | return ( 13 |
14 | 15 | Home Page 16 | 17 |
18 | 19 | 20 | Projects 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |

29 | This view shows our current projects, in-progress for the 30 | AMO team, plus you can navigate to previous and future 31 | quarters. 32 |

33 |

34 | Each project's data is provided by Github's API, and this 35 | view is a way to provide an overview of the projects per 36 | quarter for just our team. 37 |

38 | 39 | 40 | 41 |
42 | 43 |
44 |
45 |
46 | 47 | 48 | Milestones 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |

57 | Milestones are used to provide an overview of what we're 58 | shipping each week. You can also navigate to previous and 59 | next milestones. 60 |

61 |

62 | Each week we review what we're shipping with the current 63 | milestone and preview what's being worked on for the 64 | following week as part of our weekly Engineering stand-up. 65 |

66 | 71 | 72 | 73 |
74 | 75 |
76 |
77 |
78 | 79 | 80 | Dashboards 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 |

89 | These dashboards are used to give us an overview of what the 90 | issue counts look like and highlights any high priority 91 | bugs. 92 |

93 | 94 | 97 | 98 | 99 | 102 | 103 |
104 | 105 |
106 |
107 |
108 | 109 | 110 | Contributions 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 |

119 | This view shows bugs that might be suitable for a 120 | contributor to work on, this data is used as part of the 121 | bi-weekly contributor bug review. 122 |

123 | 128 | 131 | 132 |
133 | 134 |
135 |
136 |
137 |
138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /pages/milestones/[milestone].js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSWR from 'swr'; 3 | import { Helmet } from 'react-helmet'; 4 | import Link from 'next/link'; 5 | import Error from 'next/error'; 6 | import { useRouter } from 'next/router'; 7 | import { Container, Nav, Navbar, Table } from 'react-bootstrap'; 8 | import TimeAgo from 'react-timeago'; 9 | import queryString from 'query-string'; 10 | import { 11 | AlertIcon, 12 | HeartIcon, 13 | LinkIcon, 14 | PersonIcon, 15 | } from '@primer/octicons-react'; 16 | import { getMilestonePagination, formatIssueData } from 'lib/utils/milestones'; 17 | import { dateSort, sortData } from 'lib/utils/sort'; 18 | import { getApiURL } from 'lib/utils'; 19 | import { validMilestoneRX } from 'lib/const'; 20 | import ActiveLink from 'components/ActiveLink'; 21 | import HeaderLink from 'components/HeaderLink'; 22 | 23 | const defaultSort = 'assignee'; 24 | const defaultSortDir = 'asc'; 25 | const sortConfig = { 26 | assignee: {}, 27 | priority: {}, 28 | title: {}, 29 | repo: {}, 30 | updatedAt: { 31 | sortFunc: dateSort, 32 | }, 33 | hasProject: {}, 34 | state: {}, 35 | reviewersNames: {}, 36 | }; 37 | 38 | function getCurrentSortQueryString() { 39 | const router = useRouter(); 40 | const { sort, dir } = router.query; 41 | return `?${queryString.stringify({ 42 | dir: dir || defaultSortDir, 43 | sort: sort || defaultSort, 44 | })}`; 45 | } 46 | 47 | function renderAssignee(issue) { 48 | if (issue.assignees.nodes.length) { 49 | const issueAssignee = issue.assignees.nodes[0]; 50 | return ( 51 | 52 | {' '} 53 | {issueAssignee.login} 54 | 55 | ); 56 | } 57 | 58 | if (issue.assignee === '01_contributor') { 59 | return ( 60 | 61 | Contributor 62 | 63 | ); 64 | } 65 | 66 | return ( 67 | 68 | Unassigned 69 | 70 | ); 71 | } 72 | 73 | function renderReviewers(issue) { 74 | const reviewers = []; 75 | issue.reviewers.forEach((item) => { 76 | reviewers.push( 77 | 78 | 79 | 85 | 86 | , 87 | ); 88 | }); 89 | return reviewers; 90 | } 91 | 92 | function renderRows({ data }) { 93 | const rows = []; 94 | const colSpan = 7; 95 | 96 | if (!data) { 97 | return ( 98 | 99 | Loading... 100 | 101 | ); 102 | } 103 | 104 | if (data.length === 0) { 105 | return ( 106 | 107 | 108 |

There are no issues associated with this milestone yet.

109 | 110 | 111 | ); 112 | } 113 | for (let i = 0; i < data.length; i++) { 114 | const issue = data[i] || {}; 115 | rows.push( 116 | 117 | {renderAssignee(issue)} 118 | 119 | 120 | {issue.priority ? issue.priority.toUpperCase() : } 121 | 122 | 123 | 124 | 130 | #{issue.number}: {issue.title}{' '} 131 | 132 | 133 | {issue.hasProject ? ( 134 | 140 | {issue.projectName} 141 | 142 | ) : null} 143 | 144 | {issue.repository.name.replace('addons-', '')} 145 | 146 | 147 | 148 | 149 | 156 | {issue.stateLabel} 157 | 158 | 159 | {renderReviewers(issue)} 160 | , 161 | ); 162 | } 163 | return rows; 164 | } 165 | 166 | export async function getServerSideProps(props) { 167 | const { milestone } = props.params; 168 | const milestoneIssuesURL = getApiURL('/api/gh-milestone-issues/', { 169 | milestone, 170 | }); 171 | const res = await fetch(milestoneIssuesURL); 172 | const errorCode = res.ok ? false : res.status; 173 | const milestoneIssueData = await res.json(); 174 | 175 | return { 176 | props: { 177 | errorCode, 178 | milestoneIssues: formatIssueData(milestoneIssueData), 179 | }, 180 | }; 181 | } 182 | 183 | const Milestones = (props) => { 184 | if (props.errorCode) { 185 | return ; 186 | } 187 | 188 | const router = useRouter(); 189 | const { sort, dir, milestone } = router.query; 190 | const { 191 | groups: { year, month, day }, 192 | } = validMilestoneRX.exec(milestone); 193 | const milestonePagination = getMilestonePagination({ 194 | startDate: new Date(year, month - 1, day), 195 | }); 196 | const milestoneIssuesURL = getApiURL('/api/gh-milestone-issues/', { 197 | milestone, 198 | }); 199 | const initialMilestoneIssues = props.milestoneIssues; 200 | const { data: milestoneIssues } = useSWR( 201 | milestoneIssuesURL, 202 | async () => { 203 | const result = await fetch(milestoneIssuesURL); 204 | const json = await result.json(); 205 | return formatIssueData(json); 206 | }, 207 | { fallbackData: initialMilestoneIssues, refreshInterval: 30000 }, 208 | ); 209 | 210 | let data = milestoneIssues; 211 | if (sort) { 212 | data = sortData({ data, columnKey: sort, direction: dir, sortConfig }); 213 | } 214 | 215 | return ( 216 |
217 | 218 | Milestones 219 | 220 | 226 | 254 | 269 | 270 | 271 |

272 | Issues for milestone: 273 | {milestone.replace(/-/g, '.')} 274 |

275 | 276 | 277 | 278 | 281 | 284 | 287 | 290 | 293 | 296 | 299 | 300 | 301 | {renderRows({ data })} 302 |
279 | 280 | 282 | 283 | 285 | 286 | 288 | 289 | 291 | 292 | 294 | 295 | 297 | 298 |
303 |
304 |
305 | ); 306 | }; 307 | 308 | export default Milestones; 309 | -------------------------------------------------------------------------------- /pages/milestones/index.js: -------------------------------------------------------------------------------- 1 | import { getServerSideProps as latestRedirect } from './latest'; 2 | 3 | export default function Page() { 4 | return null; 5 | } 6 | 7 | export async function getServerSideProps(props) { 8 | return latestRedirect(props); 9 | } 10 | -------------------------------------------------------------------------------- /pages/milestones/latest.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string'; 2 | import { getNextMilestone, getMilestonePagination } from 'lib/utils/milestones'; 3 | 4 | export default function Page() { 5 | return null; 6 | } 7 | 8 | export async function getServerSideProps(oldProps) { 9 | let queryParams = ''; 10 | const props = { ...oldProps }; 11 | 12 | if (!props.query) { 13 | props.query = {}; 14 | } 15 | 16 | props.query = { dir: 'asc', sort: 'assignee', ...props.query }; 17 | queryParams = `?${queryString.stringify(props.query)}`; 18 | 19 | const milestonePagination = getMilestonePagination({ 20 | startDate: getNextMilestone(), 21 | }); 22 | 23 | return { 24 | redirect: { 25 | permanent: false, 26 | destination: `/milestones/${milestonePagination.current}/${queryParams}`, 27 | }, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /pages/projects/index.js: -------------------------------------------------------------------------------- 1 | import { getServerSideProps as latestRedirect } from './latest'; 2 | 3 | export default function Page() { 4 | return null; 5 | } 6 | 7 | export async function getServerSideProps(props) { 8 | return latestRedirect(props); 9 | } 10 | -------------------------------------------------------------------------------- /pages/projects/latest.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string'; 2 | import { getCurrentQuarter } from 'lib/utils/projects'; 3 | 4 | export default function Page() { 5 | return null; 6 | } 7 | 8 | export async function getServerSideProps(props) { 9 | const { year, quarter } = getCurrentQuarter(); 10 | 11 | let queryParams = ''; 12 | if (props.query) { 13 | queryParams = `?${queryString.stringify(props.query)}`; 14 | } 15 | 16 | const destination = `/projects/${year}/${quarter}/${queryParams}}`; 17 | 18 | return { 19 | redirect: { 20 | permanent: false, 21 | destination, 22 | }, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/addons-pm/15838620447612424f8d0bfee026a71cdcb0e542/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /styles/globals.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 4 | Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | height: 100%; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | a { 11 | color: #007bff; 12 | text-decoration: none !important; 13 | } 14 | 15 | .bg-dark { 16 | background-color: #343a40 !important; 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } 22 | 23 | .home { 24 | &.container { 25 | max-width: 1024px; 26 | } 27 | 28 | h3 { 29 | clear: both; 30 | margin-top: 1.5rem; 31 | } 32 | 33 | .btn { 34 | float: right; 35 | min-width: 10em; 36 | } 37 | 38 | .octicon { 39 | box-sizing: content-box; 40 | padding: 0 20px 20px 0; 41 | } 42 | 43 | .card { 44 | margin-bottom: 1.5rem; 45 | } 46 | 47 | .btn-outline-primary { 48 | margin-left: 10px; 49 | } 50 | } 51 | 52 | .filters { 53 | margin-left: 10px; 54 | } 55 | 56 | body { 57 | background: #f8f9fa !important; 58 | min-width: 400px; 59 | } 60 | 61 | @media (min-width: 1500px) { 62 | h1, 63 | .h1 { 64 | color: #666; 65 | font-size: 2rem !important; 66 | margin-top: 0.5em !important; 67 | position: sticky; 68 | text-align: center; 69 | top: 10px; 70 | z-index: 1040; 71 | } 72 | } 73 | 74 | @media (min-width: 1200px) { 75 | main.container { 76 | max-width: 1360px; 77 | } 78 | } 79 | 80 | nav[aria-label='breadcrumb'] { 81 | margin-bottom: 10px; 82 | min-width: auto; 83 | } 84 | 85 | .navbar.d-flex { 86 | display: block !important; 87 | } 88 | 89 | @media (min-width: 900px) { 90 | nav[aria-label='breadcrumb'] { 91 | margin-bottom: 0; 92 | min-width: auto; 93 | } 94 | 95 | .navbar.d-flex { 96 | display: flex !important; 97 | } 98 | } 99 | 100 | .App { 101 | text-align: center; 102 | } 103 | 104 | .App-header { 105 | height: 150px; 106 | padding: 20px; 107 | } 108 | 109 | .card-wrapper, 110 | .card { 111 | width: 100%; 112 | } 113 | 114 | .card-header { 115 | align-items: center; 116 | display: flex; 117 | font-size: 18px; 118 | justify-content: space-between; 119 | } 120 | 121 | h2.card-header { 122 | color: #333; 123 | } 124 | 125 | .card-header svg { 126 | margin-bottom: 4px; 127 | margin-right: 8px; 128 | } 129 | 130 | .navbar h1, 131 | .navbar h2 { 132 | font-size: 18px; 133 | margin-bottom: 0; 134 | } 135 | 136 | .card .progress { 137 | width: 100%; 138 | } 139 | 140 | .card .progress, 141 | .card .progressbar { 142 | border-radius: 0; 143 | height: 8px; 144 | } 145 | 146 | .updated { 147 | color: #666; 148 | font-size: 14px; 149 | line-height: 2; 150 | } 151 | 152 | .eng-image { 153 | border: 2px solid #ccc; 154 | box-sizing: content-box; 155 | margin-left: 5px; 156 | } 157 | 158 | .sidebar { 159 | bottom: 0; 160 | box-shadow: inset -1px 0 0 rgb(0 0 0 / 10%); 161 | left: 0; 162 | padding: 25px 0 0; /* Height of navbar */ 163 | position: sticky; 164 | top: 25px; 165 | z-index: -1; /* Behind the navbar */ 166 | } 167 | 168 | .sidebar-sticky { 169 | height: calc(100vh - 50px); 170 | overflow-x: hidden; 171 | overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ 172 | padding-top: 0.5rem; 173 | top: 0; 174 | } 175 | 176 | .sidebar .nav-link { 177 | color: #333; 178 | font-weight: 500; 179 | } 180 | 181 | .sidebar .nav-link .feather { 182 | color: #999; 183 | margin-right: 4px; 184 | } 185 | 186 | .sidebar .nav-link.active { 187 | color: #007bff; 188 | } 189 | 190 | .sidebar .nav-link:hover .feather, 191 | .sidebar .nav-link.active .feather { 192 | color: inherit; 193 | } 194 | 195 | .sidebar-heading { 196 | font-size: 0.75rem; 197 | text-transform: uppercase; 198 | } 199 | 200 | nav .breadcrumb { 201 | margin-bottom: 0; 202 | } 203 | 204 | .navbar-brand svg { 205 | margin-bottom: 4px; 206 | } 207 | 208 | .project-type { 209 | color: #666; 210 | font-size: 22px; 211 | margin: 20px 0 15px; 212 | } 213 | 214 | div.table-responsive { 215 | overflow-x: unset; 216 | } 217 | 218 | table.table { 219 | position: relative; 220 | 221 | thead th { 222 | background: #f8f9fa; 223 | border-top: 1px solid #dee2e6; 224 | position: sticky; 225 | top: 55px; 226 | 227 | &::after { 228 | border-bottom: 2px solid #dee2e6; 229 | bottom: -2px; 230 | content: ''; 231 | left: 0; 232 | position: absolute; 233 | width: 100%; 234 | } 235 | } 236 | } 237 | 238 | th a { 239 | &:link, 240 | &:visited, 241 | &:focus, 242 | &:active { 243 | color: #000; 244 | } 245 | 246 | &:hover { 247 | text-decoration: none; 248 | } 249 | 250 | &.sort-direction { 251 | overflow: hidden; 252 | white-space: nowrap; 253 | 254 | &::after { 255 | border-left: 5px solid transparent; 256 | border-right: 5px solid transparent; 257 | content: ''; 258 | display: inline-block; 259 | margin: 0 0 2px 5px; 260 | } 261 | 262 | &.asc::after { 263 | border-bottom: 5px solid black; 264 | } 265 | 266 | &.desc::after { 267 | border-top: 5px solid black; 268 | } 269 | } 270 | } 271 | 272 | .p1, 273 | .p2, 274 | .p3, 275 | .p4, 276 | .p5, 277 | .unprioritized { 278 | background: #999; 279 | border-radius: 3px; 280 | color: #fff; 281 | font-size: 12px; 282 | padding: 5px 5px 3px; 283 | } 284 | 285 | .p1 { 286 | background: #ff0039; 287 | } 288 | 289 | .p2 { 290 | background: #d70022; 291 | } 292 | 293 | .p3 { 294 | background: #a4000f; 295 | } 296 | 297 | .p4 { 298 | background: #5a0002; 299 | } 300 | 301 | .p5 { 302 | background: #3e0200; 303 | } 304 | 305 | .no, 306 | .yes { 307 | background: green; 308 | border-radius: 3px; 309 | color: #fff; 310 | font-size: 12px; 311 | padding: 5px 5px 3px; 312 | } 313 | 314 | .no { 315 | background: orange; 316 | } 317 | 318 | table { 319 | border: 0; 320 | margin-bottom: 20px; 321 | margin-top: 30px; 322 | max-width: 100%; 323 | width: 100%; 324 | } 325 | 326 | td.centered { 327 | text-align: center; 328 | } 329 | 330 | .Milestones { 331 | th.assignees { 332 | min-width: 10.5em; 333 | } 334 | 335 | th.repo { 336 | min-width: 8em; 337 | } 338 | 339 | th.issue { 340 | width: 45%; 341 | } 342 | 343 | h2 { 344 | margin-top: 30px; 345 | } 346 | 347 | .label { 348 | border-radius: 4px; 349 | display: inline-block; 350 | font-size: 13px; 351 | font-weight: normal; 352 | min-width: 8em; 353 | padding: 5px; 354 | text-align: center; 355 | text-transform: capitalize; 356 | } 357 | 358 | .assignee span { 359 | display: inline-block; 360 | max-width: 11em; 361 | overflow: hidden; 362 | text-overflow: ellipsis; 363 | white-space: nowrap; 364 | } 365 | 366 | .assignee, 367 | .reviewers { 368 | img, 369 | svg { 370 | border: 2px solid #ccc; 371 | border-radius: 50%; 372 | box-sizing: content-box; 373 | display: inline-block; 374 | height: 25px; 375 | margin-bottom: 2px; 376 | width: 25px; 377 | } 378 | 379 | .contributor svg { 380 | box-sizing: border-box; 381 | color: red; 382 | height: 28px; 383 | padding-top: 2px; 384 | position: relative; 385 | width: 28px; 386 | } 387 | } 388 | 389 | .reviewers { 390 | text-align: center; 391 | } 392 | 393 | .projectLink { 394 | color: #666; 395 | display: inline-block; 396 | font-size: 13px; 397 | 398 | svg { 399 | box-sizing: border-box; 400 | } 401 | } 402 | 403 | .issueLink { 404 | display: block; 405 | } 406 | } 407 | 408 | body.dash { 409 | background: #1e2430 !important; 410 | } 411 | 412 | .dashboard { 413 | .loading { 414 | color: #fff; 415 | text-align: center; 416 | } 417 | 418 | // DashCount 419 | .outer { 420 | align-items: center; 421 | display: flex; 422 | justify-content: center; 423 | overflow: visible; 424 | 425 | svg { 426 | height: 100%; 427 | } 428 | 429 | circle { 430 | fill: #3db8a4; 431 | } 432 | 433 | text { 434 | fill: #fff; 435 | font-family: sans-serif; 436 | font-size: 3.5rem; 437 | text-shadow: -1px -1px rgb(0 0 0 / 10%); 438 | } 439 | } 440 | 441 | .warning circle { 442 | fill: rgb(222 80 41); 443 | } 444 | 445 | .total circle { 446 | fill: #45a1ff; 447 | } 448 | 449 | .container { 450 | max-width: 100%; 451 | padding: 0.2rem 0.6rem; 452 | } 453 | 454 | .card-grp { 455 | display: flex; 456 | margin: 0.6rem 0; 457 | 458 | &:first-of-type, 459 | &:last-of-type { 460 | margin: 0.6rem 0; 461 | } 462 | 463 | .card { 464 | border-radius: 0; 465 | margin: 0; 466 | max-height: 100%; 467 | 468 | &:focus { 469 | z-index: 100; 470 | } 471 | 472 | &:hover { 473 | text-decoration: none !important; 474 | } 475 | 476 | &:first-child { 477 | border-bottom-left-radius: 0.25rem; 478 | border-top-left-radius: 0.25rem; 479 | } 480 | 481 | &:last-child { 482 | border-bottom-right-radius: 0.25rem; 483 | border-top-right-radius: 0.25rem; 484 | } 485 | } 486 | 487 | .card-header { 488 | color: #ccc; 489 | display: inline-block; 490 | font-weight: 200; 491 | overflow: hidden; 492 | text-overflow: ellipsis; 493 | text-shadow: -1px -1px rgb(0 0 0 / 80%); 494 | white-space: nowrap; 495 | } 496 | 497 | .title-card { 498 | flex: 0 0 13.5vw; 499 | 500 | .card-header { 501 | font-weight: 400; 502 | } 503 | } 504 | } 505 | 506 | .card svg text { 507 | font-size: 2vw; 508 | } 509 | } 510 | 511 | .Contrib { 512 | .contributor, 513 | .mentor { 514 | background: green; 515 | border-radius: 3px; 516 | color: #fff; 517 | font-size: 12px; 518 | padding: 5px 5px 3px; 519 | 520 | &.not-assigned { 521 | background: orange; 522 | } 523 | } 524 | 525 | .not-found { 526 | background: url('/static/images/blue-berror.svg') no-repeat 50% 50%; 527 | min-height: 450px; 528 | padding-top: 10px; 529 | text-align: center; 530 | } 531 | 532 | .last-updated { 533 | min-width: 9em; 534 | } 535 | 536 | th.repo { 537 | width: 11em; 538 | } 539 | } 540 | 541 | .octicon { 542 | z-index: -1; 543 | } 544 | -------------------------------------------------------------------------------- /tests/components/ActiveLink.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import mockRouter from 'next-router-mock'; 6 | import { cleanup, render, waitFor, screen } from '@testing-library/react'; 7 | import ActiveLink from 'components/ActiveLink'; 8 | 9 | // eslint-disable-next-line global-require 10 | jest.mock('next/router', () => require('next-router-mock')); 11 | 12 | describe(__filename, () => { 13 | afterEach(cleanup); 14 | 15 | it('provides non-active link', async () => { 16 | render( 17 | 18 | test-link 19 | , 20 | ); 21 | const link = await waitFor(() => screen.getByRole('link')); 22 | expect(link).toHaveAttribute('href', '/whatever/'); 23 | expect(link).not.toHaveClass('active'); 24 | }); 25 | 26 | it('provides active link', async () => { 27 | mockRouter.setCurrentUrl('/whatever'); 28 | render( 29 | 30 | test-link 31 | , 32 | ); 33 | const link = await waitFor(() => screen.getByRole('link')); 34 | expect(link).toHaveAttribute('href', '/whatever/'); 35 | expect(link).toHaveClass('active'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/components/DashCount.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render, waitFor, screen } from '@testing-library/react'; 6 | import DashCount from 'components/DashCount'; 7 | 8 | describe(__filename, () => { 9 | const testData = { 10 | link: 'https://example.com/link', 11 | title: 'title', 12 | warningLimit: 12, 13 | count: 10, 14 | }; 15 | 16 | it('renders count data', async () => { 17 | render(); 18 | await waitFor(() => screen.getByRole('link')); 19 | expect(screen.getByRole('link')).toHaveAttribute( 20 | 'href', 21 | 'https://example.com/link', 22 | ); 23 | expect(screen.getByTestId('dashcount-count')).toHaveTextContent('10'); 24 | expect(screen.getByTestId('dashcount-svg-wrapper')).toHaveClass('outer'); 25 | expect(screen.getByTestId('dashcount-svg-wrapper')).not.toHaveClass( 26 | 'warning', 27 | ); 28 | }); 29 | 30 | it('renders count data with warning', async () => { 31 | render(); 32 | await waitFor(() => screen.getByRole('link')); 33 | expect(screen.getByRole('link')).toHaveAttribute( 34 | 'href', 35 | 'https://example.com/link', 36 | ); 37 | expect(screen.getByTestId('dashcount-count')).toHaveTextContent('10'); 38 | expect(screen.getByTestId('dashcount-svg-wrapper')).toHaveClass('outer'); 39 | expect(screen.getByTestId('dashcount-svg-wrapper')).toHaveClass('warning'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/components/DashCountGroup.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render, waitFor, screen } from '@testing-library/react'; 6 | import DashCountGroup from 'components/DashCountGroup'; 7 | 8 | describe(__filename, () => { 9 | const testData = { 10 | className: 'test-class', 11 | title: 'test title', 12 | description: 'test description', 13 | }; 14 | 15 | it('renders a dashcount group', async () => { 16 | render(); 17 | await waitFor(() => screen.getByTestId('dashcountgroup')); 18 | const dashCountGroupContainer = screen.getByTestId('dashcountgroup'); 19 | expect(dashCountGroupContainer).toHaveClass('test-class'); 20 | const dashCountGroupTitle = screen.getByTestId('dashcountgroup-title'); 21 | expect(dashCountGroupTitle).toHaveTextContent(testData.title); 22 | const dashCountGroupDescription = screen.getByTestId('dashcountgroup-desc'); 23 | expect(dashCountGroupDescription).toHaveTextContent(testData.description); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/components/Engineer.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render, waitFor, screen } from '@testing-library/react'; 6 | import Engineer from 'components/Engineer'; 7 | 8 | describe(__filename, () => { 9 | const testMember = { 10 | avatarUrl: 'https://example.com/testuser', 11 | login: 'jane bloggs', 12 | }; 13 | 14 | it('loads engineer link', async () => { 15 | render(); 16 | await waitFor(() => screen.getByRole('link')); 17 | expect(screen.getByRole('link')).toHaveAttribute( 18 | 'href', 19 | '/projects/2020/Q1/?engineer=jane%20bloggs', 20 | ); 21 | expect(screen.getByRole('img')).toHaveAttribute( 22 | 'src', 23 | testMember.avatarUrl, 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/components/HeaderLink.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import mockRouter from 'next-router-mock'; 6 | import { cleanup, render, waitFor, screen } from '@testing-library/react'; 7 | import HeaderLink from 'components/HeaderLink'; 8 | 9 | // eslint-disable-next-line global-require 10 | jest.mock('next/router', () => require('next-router-mock')); 11 | 12 | describe(__filename, () => { 13 | afterEach(cleanup); 14 | 15 | it('provides opposite sort direction if column matches', async () => { 16 | mockRouter.setCurrentUrl('/foo/?dir=asc&sort=assignee'); 17 | render(); 18 | await waitFor(() => screen.getByRole('link')); 19 | const link = screen.getByRole('link'); 20 | expect(link).toHaveAttribute('href', '/foo/?dir=desc&sort=assignee'); 21 | expect(link).toHaveTextContent('Assignee'); 22 | }); 23 | 24 | it("provides default sort direction if column doesn't match", async () => { 25 | mockRouter.setCurrentUrl('/foo/?dir=desc&sort=whatever'); 26 | render(); 27 | await waitFor(() => screen.getByRole('link')); 28 | const link = screen.getByRole('link'); 29 | expect(link).toHaveAttribute('href', '/foo/?dir=desc&sort=assignee'); 30 | expect(link).toHaveTextContent('Anything you want'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/components/YesNoBool.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render, screen } from '@testing-library/react'; 6 | import YesNoBool from 'components/YesNoBool'; 7 | 8 | describe(__filename, () => { 9 | it('renders YES', async () => { 10 | // eslint-disable-next-line react/jsx-boolean-value 11 | render(); 12 | expect(screen.getByText('YES')).toHaveClass('yes'); 13 | }); 14 | 15 | it('renders NO', async () => { 16 | render(); 17 | expect(screen.getByText('NO')).toHaveClass('no'); 18 | }); 19 | 20 | it('renders YES with extra classes', async () => { 21 | /* eslint-disable react/jsx-boolean-value */ 22 | render( 23 | , 27 | ); 28 | /* eslint-enable react/jsx-boolean-value */ 29 | const span = screen.getByText('YES'); 30 | expect(span).toHaveClass('yes'); 31 | expect(span).toHaveClass('one'); 32 | expect(span).toHaveClass('two'); 33 | expect(span).not.toHaveClass('nope'); 34 | }); 35 | 36 | it('renders NO with extra classes', async () => { 37 | render( 38 | , 42 | ); 43 | const span = screen.getByText('NO'); 44 | expect(span).toHaveClass('no'); 45 | expect(span).toHaveClass('one'); 46 | expect(span).toHaveClass('two'); 47 | expect(span).not.toHaveClass('nope'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/fixtures/bz-issue-count-single.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bug_count: 20, 3 | }; 4 | -------------------------------------------------------------------------------- /tests/fixtures/bz-issue-counts.js: -------------------------------------------------------------------------------- 1 | export default { 2 | Toolkit: { 3 | 'priority-default': { 4 | count: 20, 5 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&priority=--&product=Toolkit&resolution=---', 6 | }, 7 | 'priority-p1': { 8 | count: 20, 9 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&priority=P1&product=Toolkit&resolution=---', 10 | }, 11 | 'priority-p2': { 12 | count: 20, 13 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&priority=P2&product=Toolkit&resolution=---', 14 | }, 15 | 'severity-normal': { 16 | count: 20, 17 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=normal&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&product=Toolkit&resolution=---', 18 | }, 19 | 'severity-default': { 20 | count: 20, 21 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=--&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&product=Toolkit&resolution=---', 22 | }, 23 | 'severity-not-applicable': { 24 | count: 20, 25 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=N%2FA&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&product=Toolkit&resolution=---', 26 | }, 27 | 'severity-s1': { 28 | count: 20, 29 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=S1&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&product=Toolkit&resolution=---', 30 | }, 31 | 'severity-s2': { 32 | count: 20, 33 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=S2&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&product=Toolkit&resolution=---', 34 | }, 35 | total: { 36 | count: 20, 37 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&limit=0&priority&product=Toolkit&resolution=---', 38 | }, 39 | }, 40 | WebExtensions: { 41 | 'priority-default': { 42 | count: 20, 43 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&priority=--&product=WebExtensions&resolution=---', 44 | }, 45 | 'priority-p1': { 46 | count: 20, 47 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&priority=P1&product=WebExtensions&resolution=---', 48 | }, 49 | 'priority-p2': { 50 | count: 20, 51 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&priority=P2&product=WebExtensions&resolution=---', 52 | }, 53 | 'severity-normal': { 54 | count: 20, 55 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=normal&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&product=WebExtensions&resolution=---', 56 | }, 57 | 'severity-default': { 58 | count: 20, 59 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=--&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&product=WebExtensions&resolution=---', 60 | }, 61 | 'severity-not-applicable': { 62 | count: 20, 63 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=N%2FA&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&product=WebExtensions&resolution=---', 64 | }, 65 | 'severity-s1': { 66 | count: 20, 67 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=S1&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&product=WebExtensions&resolution=---', 68 | }, 69 | 'severity-s2': { 70 | count: 20, 71 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity=S2&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&product=WebExtensions&resolution=---', 72 | }, 73 | total: { 74 | count: 20, 75 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_severity&bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&limit=0&priority&product=WebExtensions&resolution=---', 76 | }, 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /tests/fixtures/bz-need-infos-single.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bugs: [ 3 | { 4 | id: 111111, 5 | }, 6 | { 7 | id: 222222, 8 | }, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /tests/fixtures/bz-need-infos.js: -------------------------------------------------------------------------------- 1 | export default { 2 | testuser: { 3 | count: 2, 4 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_id=111111,222222', 5 | }, 6 | testuser2: { 7 | count: 2, 8 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_id=111111,222222', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /tests/fixtures/bz-whiteboard-tags-single.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bug_count: 4, 3 | }; 4 | -------------------------------------------------------------------------------- /tests/fixtures/bz-whiteboard-tags.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'addons-ux': { 3 | count: 4, 4 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&component=Android&component=Compatibility&component=Developer%20Outreach&component=Developer%20Tools&component=Experiments&component=Frontend&component=General&component=Request%20Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions&resolution=---&status_whiteboard=addons-ux&status_whiteboard_type=allwordssubstr', 5 | }, 6 | prod_bug: { 7 | count: 4, 8 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&component=Android&component=Compatibility&component=Developer%20Outreach&component=Developer%20Tools&component=Experiments&component=Frontend&component=General&component=Request%20Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions&resolution=---&status_whiteboard=prod_bug&status_whiteboard_type=allwordssubstr', 9 | }, 10 | 'stockwell': { 11 | count: 4, 12 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&component=Android&component=Compatibility&component=Developer%20Outreach&component=Developer%20Tools&component=Experiments&component=Frontend&component=General&component=Request%20Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions&resolution=---&status_whiteboard=stockwell&status_whiteboard_type=allwordssubstr', 13 | }, 14 | '[mv3-m1]': { 15 | count: 4, 16 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&component=Android&component=Compatibility&component=Developer%20Outreach&component=Developer%20Tools&component=Experiments&component=Frontend&component=General&component=Request%20Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions&resolution=---&status_whiteboard=%5Bmv3-m1%5D&status_whiteboard_type=allwordssubstr', 17 | }, 18 | '[mv3-m2]': { 19 | count: 4, 20 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&component=Android&component=Compatibility&component=Developer%20Outreach&component=Developer%20Tools&component=Experiments&component=Frontend&component=General&component=Request%20Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions&resolution=---&status_whiteboard=%5Bmv3-m2%5D&status_whiteboard_type=allwordssubstr', 21 | }, 22 | '[mv3-m3]': { 23 | count: 4, 24 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&component=Android&component=Compatibility&component=Developer%20Outreach&component=Developer%20Tools&component=Experiments&component=Frontend&component=General&component=Request%20Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions&resolution=---&status_whiteboard=%5Bmv3-m3%5D&status_whiteboard_type=allwordssubstr', 25 | }, 26 | '[mv3-future]': { 27 | count: 4, 28 | url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_status=ASSIGNED&bug_status=NEW&bug_status=REOPENED&bug_status=UNCONFIRMED&component=Add-ons%20Manager&component=Android&component=Compatibility&component=Developer%20Outreach&component=Developer%20Tools&component=Experiments&component=Frontend&component=General&component=Request%20Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions&resolution=---&status_whiteboard=%5Bmv3-future%5D&status_whiteboard_type=allwordssubstr', 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /tests/fixtures/gh-contrib-welcome.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | contrib_welcome: { 4 | issueCount: 3, 5 | results: [ 6 | { 7 | issue: { 8 | number: 2248, 9 | updatedAt: '2019-03-25T17:27:07Z', 10 | title: 11 | 'src/badwords.json false positive "dago" is benign "there is" in Basque.', 12 | url: 'https://github.com/mozilla/addons-linter/issues/2248', 13 | repository: { 14 | name: 'addons-linter', 15 | __typename: 'Repository', 16 | }, 17 | labels: { 18 | nodes: [ 19 | { 20 | name: 'contrib: assigned', 21 | __typename: 'Label', 22 | }, 23 | { 24 | name: 'contrib: welcome', 25 | __typename: 'Label', 26 | }, 27 | { 28 | name: 'contrib: mentor assigned', 29 | __typename: 'Label', 30 | }, 31 | { 32 | name: 'priority:p5', 33 | __typename: 'Label', 34 | }, 35 | ], 36 | __typename: 'LabelConnection', 37 | }, 38 | __typename: 'Issue', 39 | }, 40 | __typename: 'SearchResultItemEdge', 41 | }, 42 | { 43 | issue: { 44 | number: 1685, 45 | updatedAt: '2019-03-20T15:51:59Z', 46 | title: 'Warn about more APIs with a temporary ID', 47 | url: 'https://github.com/mozilla/addons-linter/issues/1685', 48 | repository: { 49 | name: 'addons-linter', 50 | __typename: 'Repository', 51 | }, 52 | labels: { 53 | nodes: [ 54 | { 55 | name: 'contrib: welcome', 56 | __typename: 'Label', 57 | }, 58 | { 59 | name: 'contrib: mentor assigned', 60 | __typename: 'Label', 61 | }, 62 | { 63 | name: 'priority:p3', 64 | __typename: 'Label', 65 | }, 66 | { 67 | name: 'triaged', 68 | __typename: 'Label', 69 | }, 70 | ], 71 | __typename: 'LabelConnection', 72 | }, 73 | __typename: 'Issue', 74 | }, 75 | __typename: 'SearchResultItemEdge', 76 | }, 77 | { 78 | issue: { 79 | number: 536, 80 | updatedAt: '2019-01-23T19:45:20Z', 81 | title: "[Reviewer Tools] Fixed Generic Title isn't informative", 82 | url: 'https://github.com/mozilla/addons/issues/536', 83 | repository: { 84 | name: 'addons', 85 | __typename: 'Repository', 86 | }, 87 | labels: { 88 | nodes: [ 89 | { 90 | name: 'component: reviewer tools', 91 | __typename: 'Label', 92 | }, 93 | { 94 | name: 'contrib: welcome', 95 | __typename: 'Label', 96 | }, 97 | { 98 | name: 'contrib: mentor assigned', 99 | __typename: 'Label', 100 | }, 101 | { 102 | name: 'contrib: welcome', 103 | __typename: 'Label', 104 | }, 105 | { 106 | name: 'priority:p4', 107 | __typename: 'Label', 108 | }, 109 | { 110 | name: 'triaged', 111 | __typename: 'Label', 112 | }, 113 | ], 114 | __typename: 'LabelConnection', 115 | }, 116 | __typename: 'Issue', 117 | }, 118 | __typename: 'SearchResultItemEdge', 119 | }, 120 | ], 121 | __typename: 'SearchResultItemConnection', 122 | }, 123 | }, 124 | loading: false, 125 | networkStatus: 7, 126 | stale: false, 127 | }; 128 | -------------------------------------------------------------------------------- /tests/fixtures/gh-good-first-bugs.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | good_first_bugs: { 4 | issueCount: 3, 5 | results: [ 6 | { 7 | issue: { 8 | number: 2248, 9 | updatedAt: '2019-03-25T17:27:07Z', 10 | title: 11 | 'src/badwords.json false positive "dago" is benign "there is" in Basque.', 12 | url: 'https://github.com/mozilla/addons-linter/issues/2248', 13 | repository: { 14 | name: 'addons-linter', 15 | __typename: 'Repository', 16 | }, 17 | labels: { 18 | nodes: [ 19 | { 20 | name: 'contrib: assigned', 21 | __typename: 'Label', 22 | }, 23 | { 24 | name: 'contrib: good first bug', 25 | __typename: 'Label', 26 | }, 27 | { 28 | name: 'contrib: mentor assigned', 29 | __typename: 'Label', 30 | }, 31 | { 32 | name: 'priority:p5', 33 | __typename: 'Label', 34 | }, 35 | ], 36 | __typename: 'LabelConnection', 37 | }, 38 | __typename: 'Issue', 39 | }, 40 | __typename: 'SearchResultItemEdge', 41 | }, 42 | { 43 | issue: { 44 | number: 1685, 45 | updatedAt: '2019-03-20T15:51:59Z', 46 | title: 'Warn about more APIs with a temporary ID', 47 | url: 'https://github.com/mozilla/addons-linter/issues/1685', 48 | repository: { 49 | name: 'addons-linter', 50 | __typename: 'Repository', 51 | }, 52 | labels: { 53 | nodes: [ 54 | { 55 | name: 'contrib: good first bug', 56 | __typename: 'Label', 57 | }, 58 | { 59 | name: 'contrib: mentor assigned', 60 | __typename: 'Label', 61 | }, 62 | { 63 | name: 'priority:p3', 64 | __typename: 'Label', 65 | }, 66 | { 67 | name: 'triaged', 68 | __typename: 'Label', 69 | }, 70 | ], 71 | __typename: 'LabelConnection', 72 | }, 73 | __typename: 'Issue', 74 | }, 75 | __typename: 'SearchResultItemEdge', 76 | }, 77 | { 78 | issue: { 79 | number: 536, 80 | updatedAt: '2019-01-23T19:45:20Z', 81 | title: "[Reviewer Tools] Fixed Generic Title isn't informative", 82 | url: 'https://github.com/mozilla/addons/issues/536', 83 | repository: { 84 | name: 'addons', 85 | __typename: 'Repository', 86 | }, 87 | labels: { 88 | nodes: [ 89 | { 90 | name: 'component: reviewer tools', 91 | __typename: 'Label', 92 | }, 93 | { 94 | name: 'contrib: good first bug', 95 | __typename: 'Label', 96 | }, 97 | { 98 | name: 'contrib: mentor assigned', 99 | __typename: 'Label', 100 | }, 101 | { 102 | name: 'contrib: welcome', 103 | __typename: 'Label', 104 | }, 105 | { 106 | name: 'priority:p4', 107 | __typename: 'Label', 108 | }, 109 | { 110 | name: 'triaged', 111 | __typename: 'Label', 112 | }, 113 | ], 114 | __typename: 'LabelConnection', 115 | }, 116 | __typename: 'Issue', 117 | }, 118 | __typename: 'SearchResultItemEdge', 119 | }, 120 | ], 121 | __typename: 'SearchResultItemConnection', 122 | }, 123 | }, 124 | loading: false, 125 | networkStatus: 7, 126 | stale: false, 127 | }; 128 | -------------------------------------------------------------------------------- /tests/fixtures/gh-issue-counts.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | addons: { 4 | description: '☂ Umbrella repository for Mozilla Addons ✨', 5 | total_issues: { 6 | totalCount: 108, 7 | __typename: 'IssueConnection', 8 | }, 9 | triaged: { 10 | totalCount: 102, 11 | __typename: 'IssueConnection', 12 | }, 13 | open_prod_bugs: { 14 | totalCount: 0, 15 | __typename: 'IssueConnection', 16 | }, 17 | open_p1s: { 18 | totalCount: 0, 19 | __typename: 'IssueConnection', 20 | }, 21 | open_p2s: { 22 | totalCount: 2, 23 | __typename: 'IssueConnection', 24 | }, 25 | open_p3s: { 26 | totalCount: 3, 27 | __typename: 'IssueConnection', 28 | }, 29 | open_prs: { 30 | totalCount: 0, 31 | __typename: 'PullRequestConnection', 32 | }, 33 | __typename: 'Repository', 34 | }, 35 | addons_server: { 36 | description: '🕶 addons.mozilla.org Django app and API 🎉', 37 | total_issues: { 38 | totalCount: 397, 39 | __typename: 'IssueConnection', 40 | }, 41 | triaged: { 42 | totalCount: 380, 43 | __typename: 'IssueConnection', 44 | }, 45 | open_prod_bugs: { 46 | totalCount: 0, 47 | __typename: 'IssueConnection', 48 | }, 49 | open_p1s: { 50 | totalCount: 0, 51 | __typename: 'IssueConnection', 52 | }, 53 | open_p2s: { 54 | totalCount: 1, 55 | __typename: 'IssueConnection', 56 | }, 57 | open_p3s: { 58 | totalCount: 5, 59 | __typename: 'IssueConnection', 60 | }, 61 | open_prs: { 62 | totalCount: 3, 63 | __typename: 'PullRequestConnection', 64 | }, 65 | __typename: 'Repository', 66 | }, 67 | addons_frontend: { 68 | description: 'Front-end to complement mozilla/addons-server', 69 | total_issues: { 70 | totalCount: 361, 71 | __typename: 'IssueConnection', 72 | }, 73 | triaged: { 74 | totalCount: 357, 75 | __typename: 'IssueConnection', 76 | }, 77 | open_prod_bugs: { 78 | totalCount: 0, 79 | __typename: 'IssueConnection', 80 | }, 81 | open_p1s: { 82 | totalCount: 0, 83 | __typename: 'IssueConnection', 84 | }, 85 | open_p2s: { 86 | totalCount: 3, 87 | __typename: 'IssueConnection', 88 | }, 89 | open_p3s: { 90 | totalCount: 6, 91 | __typename: 'IssueConnection', 92 | }, 93 | open_prs: { 94 | totalCount: 10, 95 | __typename: 'PullRequestConnection', 96 | }, 97 | __typename: 'Repository', 98 | }, 99 | addons_blog: { 100 | description: 'Blog content builder for AMO', 101 | total_issues: { 102 | totalCount: 3, 103 | __typename: 'IssueConnection', 104 | }, 105 | triaged: { 106 | totalCount: 0, 107 | __typename: 'IssueConnection', 108 | }, 109 | open_prod_bugs: { 110 | totalCount: 0, 111 | __typename: 'IssueConnection', 112 | }, 113 | open_p1s: { 114 | totalCount: 0, 115 | __typename: 'IssueConnection', 116 | }, 117 | open_p2s: { 118 | totalCount: 0, 119 | __typename: 'IssueConnection', 120 | }, 121 | open_p3s: { 122 | totalCount: 0, 123 | __typename: 'IssueConnection', 124 | }, 125 | open_prs: { 126 | totalCount: 0, 127 | __typename: 'PullRequestConnection', 128 | }, 129 | __typename: 'Repository', 130 | }, 131 | addons_linter: { 132 | description: '🔍 Firefox Add-ons linter, written in JavaScript. 👁', 133 | total_issues: { 134 | totalCount: 98, 135 | __typename: 'IssueConnection', 136 | }, 137 | triaged: { 138 | totalCount: 86, 139 | __typename: 'IssueConnection', 140 | }, 141 | open_prod_bugs: { 142 | totalCount: 0, 143 | __typename: 'IssueConnection', 144 | }, 145 | open_p1s: { 146 | totalCount: 0, 147 | __typename: 'IssueConnection', 148 | }, 149 | open_p2s: { 150 | totalCount: 0, 151 | __typename: 'IssueConnection', 152 | }, 153 | open_p3s: { 154 | totalCount: 4, 155 | __typename: 'IssueConnection', 156 | }, 157 | open_prs: { 158 | totalCount: 2, 159 | __typename: 'PullRequestConnection', 160 | }, 161 | __typename: 'Repository', 162 | }, 163 | addons_code_manager: { 164 | description: 'A web application to manage add-on source code', 165 | total_issues: { 166 | totalCount: 54, 167 | __typename: 'IssueConnection', 168 | }, 169 | triaged: { 170 | totalCount: 53, 171 | __typename: 'IssueConnection', 172 | }, 173 | open_prod_bugs: { 174 | totalCount: 0, 175 | __typename: 'IssueConnection', 176 | }, 177 | open_p1s: { 178 | totalCount: 0, 179 | __typename: 'IssueConnection', 180 | }, 181 | open_p2s: { 182 | totalCount: 0, 183 | __typename: 'IssueConnection', 184 | }, 185 | open_p3s: { 186 | totalCount: 4, 187 | __typename: 'IssueConnection', 188 | }, 189 | open_prs: { 190 | totalCount: 10, 191 | __typename: 'IssueConnection', 192 | }, 193 | __typename: 'Repository', 194 | }, 195 | }, 196 | loading: false, 197 | networkStatus: 7, 198 | stale: false, 199 | }; 200 | -------------------------------------------------------------------------------- /tests/fixtures/gh-maybe-good-first-bugs.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | maybe_good_first_bugs: { 4 | issueCount: 1, 5 | results: [ 6 | { 7 | issue: { 8 | number: 3580, 9 | updatedAt: '2019-03-25T18:41:51Z', 10 | title: 'Hovering over an outgoing URL should show the URL', 11 | url: 'https://github.com/mozilla/addons-frontend/issues/3580', 12 | repository: { 13 | name: 'addons-frontend', 14 | __typename: 'Repository', 15 | }, 16 | labels: { 17 | nodes: [ 18 | { 19 | name: 'contrib: maybe good first bug', 20 | __typename: 'Label', 21 | }, 22 | { 23 | name: 'priority:p4', 24 | __typename: 'Label', 25 | }, 26 | { 27 | name: 'project: amo', 28 | __typename: 'Label', 29 | }, 30 | { 31 | name: 'triaged', 32 | __typename: 'Label', 33 | }, 34 | ], 35 | __typename: 'LabelConnection', 36 | }, 37 | __typename: 'Issue', 38 | }, 39 | __typename: 'SearchResultItemEdge', 40 | }, 41 | ], 42 | __typename: 'SearchResultItemConnection', 43 | }, 44 | }, 45 | loading: false, 46 | networkStatus: 7, 47 | stale: false, 48 | }; 49 | -------------------------------------------------------------------------------- /tests/fixtures/gh-projects.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | organization: { 4 | projects: { 5 | nodes: [ 6 | { 7 | name: 'Add-ons Q3 2018: Test Project 1', 8 | state: 'OPEN', 9 | url: 'https://github.com/orgs/mozilla/projects/2', 10 | bodyHTML: `My Project Data HTML 11 |
12 | Project Metadata 13 |
14 |
Engineering
15 |
@testuser
16 |
Goal Type
17 |
Primary
18 |
19 |
`, 20 | updatedAt: '2018-09-18T15:56:15Z', 21 | columns: { 22 | edges: [ 23 | { 24 | node: { 25 | id: 'id1', 26 | name: 'To do', 27 | cards: { 28 | totalCount: 10, 29 | __typename: 'ProjectCardConnection', 30 | }, 31 | __typename: 'ProjectColumn', 32 | }, 33 | __typename: 'ProjectColumnEdge', 34 | }, 35 | { 36 | node: { 37 | id: 'id2', 38 | name: 'In progress', 39 | cards: { 40 | totalCount: 1, 41 | __typename: 'ProjectCardConnection', 42 | }, 43 | __typename: 'ProjectColumn', 44 | }, 45 | __typename: 'ProjectColumnEdge', 46 | }, 47 | { 48 | node: { 49 | id: 'id3', 50 | name: 'Done', 51 | cards: { 52 | totalCount: 6, 53 | __typename: 'ProjectCardConnection', 54 | }, 55 | __typename: 'ProjectColumn', 56 | }, 57 | __typename: 'ProjectColumnEdge', 58 | }, 59 | ], 60 | __typename: 'ProjectColumnConnection', 61 | }, 62 | __typename: 'Project', 63 | }, 64 | { 65 | name: 'Add-ons Q3 2018: Test Project 2', 66 | state: 'OPEN', 67 | url: 'https://github.com/orgs/mozilla/projects/2', 68 | bodyHTML: 'My Project Data HTML 2', 69 | updatedAt: '2018-08-18T15:55:15Z', 70 | columns: { 71 | edges: [ 72 | { 73 | node: { 74 | id: 'id4', 75 | name: 'To do', 76 | cards: { 77 | totalCount: 5, 78 | __typename: 'ProjectCardConnection', 79 | }, 80 | __typename: 'ProjectColumn', 81 | }, 82 | __typename: 'ProjectColumnEdge', 83 | }, 84 | { 85 | node: { 86 | id: 'id5', 87 | name: 'In progress', 88 | cards: { 89 | totalCount: 5, 90 | __typename: 'ProjectCardConnection', 91 | }, 92 | __typename: 'ProjectColumn', 93 | }, 94 | __typename: 'ProjectColumnEdge', 95 | }, 96 | { 97 | node: { 98 | id: 'id6', 99 | name: 'Done', 100 | cards: { 101 | totalCount: 5, 102 | __typename: 'ProjectCardConnection', 103 | }, 104 | __typename: 'ProjectColumn', 105 | }, 106 | __typename: 'ProjectColumnEdge', 107 | }, 108 | ], 109 | __typename: 'ProjectColumnConnection', 110 | }, 111 | __typename: 'Project', 112 | }, 113 | { 114 | name: 'Add-ons Q3 2018: Test Project 3', 115 | state: 'OPEN', 116 | url: 'https://github.com/orgs/mozilla/projects/3', 117 | bodyHTML: `My Project Data HTML 118 |
119 | Project Metadata 120 |
121 |
Engineering
122 |
@testuser-2
123 |
Goal Type
124 |
Secondary
125 |
126 |
`, 127 | updatedAt: '2018-08-18T13:56:15Z', 128 | columns: { 129 | edges: [ 130 | { 131 | node: { 132 | id: 'id7', 133 | name: 'To do', 134 | cards: { 135 | totalCount: 1, 136 | __typename: 'ProjectCardConnection', 137 | }, 138 | __typename: 'ProjectColumn', 139 | }, 140 | __typename: 'ProjectColumnEdge', 141 | }, 142 | { 143 | node: { 144 | id: 'id8', 145 | name: 'In progress', 146 | cards: { 147 | totalCount: 1, 148 | __typename: 'ProjectCardConnection', 149 | }, 150 | __typename: 'ProjectColumn', 151 | }, 152 | __typename: 'ProjectColumnEdge', 153 | }, 154 | { 155 | node: { 156 | id: 'id9', 157 | name: 'Done', 158 | cards: { 159 | totalCount: 6, 160 | __typename: 'ProjectCardConnection', 161 | }, 162 | __typename: 'ProjectColumn', 163 | }, 164 | __typename: 'ProjectColumnEdge', 165 | }, 166 | ], 167 | __typename: 'ProjectColumnConnection', 168 | }, 169 | __typename: 'Project', 170 | }, 171 | { 172 | name: 'Add-ons Q3 2018: Test Project 4', 173 | state: 'OPEN', 174 | url: 'https://github.com/orgs/mozilla/projects/4', 175 | bodyHTML: `My Project Data HTML 176 |
177 | Project Metadata 178 |
179 |
Engineering
180 |
@testuser-2
181 |
Goal Type
182 |
Wrong
183 |
184 |
`, 185 | updatedAt: '2018-07-18T13:56:15Z', 186 | columns: { 187 | edges: [ 188 | { 189 | node: { 190 | id: 'id10', 191 | name: 'To do', 192 | cards: { 193 | totalCount: 3, 194 | __typename: 'ProjectCardConnection', 195 | }, 196 | __typename: 'ProjectColumn', 197 | }, 198 | __typename: 'ProjectColumnEdge', 199 | }, 200 | { 201 | node: { 202 | id: 'id11', 203 | name: 'In progress', 204 | cards: { 205 | totalCount: 4, 206 | __typename: 'ProjectCardConnection', 207 | }, 208 | __typename: 'ProjectColumn', 209 | }, 210 | __typename: 'ProjectColumnEdge', 211 | }, 212 | { 213 | node: { 214 | id: 'id12', 215 | name: 'Done', 216 | cards: { 217 | totalCount: 6, 218 | __typename: 'ProjectCardConnection', 219 | }, 220 | __typename: 'ProjectColumn', 221 | }, 222 | __typename: 'ProjectColumnEdge', 223 | }, 224 | ], 225 | __typename: 'ProjectColumnConnection', 226 | }, 227 | __typename: 'Project', 228 | }, 229 | ], 230 | __typename: 'ProjectConnection', 231 | }, 232 | __typename: 'Organization', 233 | }, 234 | }, 235 | loading: false, 236 | networkStatus: 7, 237 | stale: false, 238 | }; 239 | -------------------------------------------------------------------------------- /tests/fixtures/gh-team.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: { 3 | search: { 4 | edges: [ 5 | { 6 | node: { 7 | id: 'SomeId==', 8 | avatarUrl: 'https://avatars.githubusercontent.com/u/zzz?v=4', 9 | login: 'testuser-1', 10 | name: 'A User', 11 | __typename: 'User', 12 | }, 13 | __typename: 'SearchResultItemEdge', 14 | }, 15 | { 16 | node: { 17 | id: 'AnotherId==', 18 | avatarUrl: 'https://avatars.githubusercontent.com/u/yyyy?v=4', 19 | login: 'testuser-2', 20 | name: 'Another User', 21 | __typename: 'User', 22 | }, 23 | __typename: 'SearchResultItemEdge', 24 | }, 25 | { 26 | node: { 27 | id: 'AThirdId==', 28 | avatarUrl: 'https://avatars.githubusercontent.com/u/xxxx?v=4', 29 | login: 'testuser-3', 30 | name: 'A Third User', 31 | __typename: 'User', 32 | }, 33 | __typename: 'SearchResultItemEdge', 34 | }, 35 | ], 36 | __typename: 'SearchResultItemConnection', 37 | }, 38 | }, 39 | loading: false, 40 | networkStatus: 7, 41 | stale: false, 42 | }; 43 | -------------------------------------------------------------------------------- /tests/lib/const.test.js: -------------------------------------------------------------------------------- 1 | import * as constants from 'lib/const'; 2 | 3 | describe(__filename, () => { 4 | describe('validYearRX', () => { 5 | it('should match valid content', () => { 6 | expect('2019').toMatch(constants.validYearRX); 7 | expect('2020').toMatch(constants.validYearRX); 8 | }); 9 | 10 | it('should not match invalid content', () => { 11 | expect('201113').not.toMatch(constants.validYearRX); 12 | expect('1977').not.toMatch(constants.validYearRX); 13 | expect('2016').not.toMatch(constants.validYearRX); 14 | }); 15 | }); 16 | 17 | describe('validQuarterRX', () => { 18 | it('should match valid content', () => { 19 | expect('Q1').toMatch(constants.validQuarterRX); 20 | expect('Q2').toMatch(constants.validQuarterRX); 21 | expect('Q3').toMatch(constants.validQuarterRX); 22 | expect('Q4').toMatch(constants.validQuarterRX); 23 | }); 24 | 25 | it('should not match invalid content', () => { 26 | expect('Q5').not.toMatch(constants.validQuarterRX); 27 | expect('whatever').not.toMatch(constants.validQuarterRX); 28 | }); 29 | }); 30 | 31 | describe('validMilestoneRX', () => { 32 | it('should match valid content', () => { 33 | expect('2019-04-04').toMatch(constants.validMilestoneRX); 34 | expect('2018-03-12').toMatch(constants.validMilestoneRX); 35 | expect('2019-12-31').toMatch(constants.validMilestoneRX); 36 | expect('2019-01-01').toMatch(constants.validMilestoneRX); 37 | }); 38 | 39 | it('should not match invalid content', () => { 40 | expect('2016-11-11').not.toMatch(constants.validMilestoneRX); 41 | expect('2019-11-32').not.toMatch(constants.validMilestoneRX); 42 | expect('2019-13-32').not.toMatch(constants.validMilestoneRX); 43 | expect('whatever').not.toMatch(constants.validMilestoneRX); 44 | }); 45 | 46 | it('should have named groups', () => { 47 | const result = constants.validMilestoneRX.exec('2020-12-11'); 48 | const { year, day, month } = result.groups; 49 | expect(year).toEqual('2020'); 50 | expect(month).toEqual('12'); 51 | expect(day).toEqual('11'); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/lib/serverSWR.test.js: -------------------------------------------------------------------------------- 1 | import serverSWR from 'lib/serverSWR'; 2 | 3 | describe(__filename, () => { 4 | let fakeCache; 5 | let fakeFetcher; 6 | 7 | beforeEach(() => { 8 | fakeFetcher = jest.fn(); 9 | fakeCache = { 10 | has: jest.fn(), 11 | get: jest.fn(), 12 | set: jest.fn(), 13 | }; 14 | }); 15 | 16 | it('should return fresh data', async () => { 17 | fakeCache.has.mockReturnValueOnce(true); 18 | fakeCache.get.mockReturnValueOnce({ 19 | response: { test: 'foo-data' }, 20 | timestamp: new Date().getTime() - 5000, 21 | }); 22 | const result = await serverSWR('foo', fakeFetcher, { swrCache: fakeCache }); 23 | // calls[0][0] first call and first arg. 24 | expect(fakeCache.has.mock.calls[0][0]).toEqual('foo'); 25 | expect(fakeCache.get.mock.calls[0][0]).toEqual('foo'); 26 | // Based on the time being older than now, the fetcher shouldn't be called. 27 | expect(fakeFetcher.mock.calls.length).toEqual(0); 28 | // Based on the time being older than now, there should be nothing new to cache. 29 | expect(fakeCache.set.mock.calls.length).toEqual(0); 30 | expect(result.test).toEqual('foo-data'); 31 | }); 32 | 33 | it('should return stale data first, followed by fresh data', async () => { 34 | fakeCache.has.mockReturnValue(true); 35 | fakeCache.get 36 | .mockReturnValueOnce({ 37 | response: { test: 'foo-data' }, 38 | // Older than 5 minutes in the past in milliseconds. 39 | timestamp: new Date().getTime() - (5 * 60 * 1000 + 5), 40 | }) 41 | .mockReturnValueOnce({ 42 | response: { test: 'new-foo-data' }, 43 | timestamp: new Date().getTime() - 5000, 44 | }); 45 | const result = await serverSWR('foo', fakeFetcher, { swrCache: fakeCache }); 46 | // calls[0][0] first call and first arg. 47 | expect(fakeCache.has.mock.calls[0][0]).toEqual('foo'); 48 | expect(fakeCache.get.mock.calls[0][0]).toEqual('foo'); 49 | // Based on the time being newer than cached data the fetcher should be called. 50 | expect(fakeFetcher.mock.calls.length).toEqual(1); 51 | // Based on the time being newer than now, the cache should be updated with a new 52 | // timestamp to prevent premature re-fetches as well as providing the new data. 53 | expect(fakeCache.set.mock.calls.length).toEqual(2); 54 | // Data should be stale the first time. 55 | expect(result.test).toEqual('foo-data'); 56 | const newResult = await serverSWR('foo', fakeFetcher, { 57 | swrCache: fakeCache, 58 | }); 59 | // On the second call it should be fresh. 60 | expect(newResult.test).toEqual('new-foo-data'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /tests/lib/utils/contrib.test.js: -------------------------------------------------------------------------------- 1 | import { formatContribData } from 'lib/utils/contrib'; 2 | 3 | describe(__filename, () => { 4 | describe('formatContribData()', () => { 5 | const testIssues = [ 6 | { 7 | issue: { 8 | state: 'OPEN', 9 | number: 1, 10 | updatedAt: '2019-08-16T15:27:22Z', 11 | title: 'Example issue title', 12 | url: 'https://example.com/issues/1', 13 | repository: { 14 | name: 'reponame', 15 | }, 16 | labels: { 17 | nodes: [ 18 | { 19 | name: 'priority:p1', 20 | }, 21 | { 22 | name: 'contrib: assigned', 23 | }, 24 | ], 25 | }, 26 | assignees: { 27 | nodes: [ 28 | { 29 | id: '367468234', 30 | name: 'User Name', 31 | login: 'example-username', 32 | avatarUrl: 'https://example.com/avatar/id/367468234', 33 | }, 34 | ], 35 | }, 36 | }, 37 | }, 38 | { 39 | issue: { 40 | state: 'OPEN', 41 | number: 2, 42 | updatedAt: '2019-08-16T15:27:22Z', 43 | title: 'Example issue title 2', 44 | url: 'https://example.com/issues/2', 45 | repository: { 46 | name: 'reponame', 47 | }, 48 | labels: { 49 | nodes: [ 50 | { 51 | name: 'priority:p2', 52 | }, 53 | { 54 | name: 'contrib: mentor assigned', 55 | }, 56 | ], 57 | }, 58 | assignees: { 59 | nodes: [ 60 | { 61 | id: '367468234', 62 | name: 'User Name', 63 | login: 'example-username', 64 | avatarUrl: 'https://example.com/avatar/id/367468234', 65 | }, 66 | ], 67 | }, 68 | }, 69 | }, 70 | ]; 71 | 72 | it('adds assignment props to issues with assignment labels', () => { 73 | const formattedData = formatContribData(testIssues); 74 | expect(formattedData[0].assigned).toEqual(true); 75 | expect(formattedData[1].mentorAssigned).toEqual(true); 76 | }); 77 | 78 | it('adds priority prop to issues with priority labels', () => { 79 | const formattedData = formatContribData(testIssues); 80 | expect(formattedData[0].priority).toEqual('p1'); 81 | expect(formattedData[1].priority).toEqual('p2'); 82 | }); 83 | 84 | it('adds repo name to issue object', () => { 85 | const formattedData = formatContribData(testIssues); 86 | expect(formattedData[0].repo).toEqual('reponame'); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /tests/lib/utils/index.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | colourIsLight, 3 | getApiURL, 4 | hasLabel, 5 | hasLabelContainingString, 6 | hexToRgb, 7 | sanitize, 8 | } from 'lib/utils'; 9 | 10 | describe(__filename, () => { 11 | it('sanitize() sanitizes bad markup', () => { 12 | const sanitized = sanitize( 13 | 'foo', 14 | ); 15 | expect(sanitized).not.toEqual(expect.stringMatching('javascript')); 16 | }); 17 | 18 | it('sanitize() adds target="_blank" and rel="noopener noreferrer" to links', () => { 19 | const sanitized = sanitize('foo'); 20 | expect(sanitized).toEqual( 21 | expect.stringMatching('rel="noopener noreferrer"'), 22 | ); 23 | expect(sanitized).toEqual(expect.stringMatching('target="_blank"')); 24 | }); 25 | 26 | it('hexToRgb() converts hex to rgb', () => { 27 | const { r, g, b } = hexToRgb('#ffffff'); 28 | expect(r).toEqual(255); 29 | expect(g).toEqual(255); 30 | expect(b).toEqual(255); 31 | }); 32 | 33 | it('colourIsLight() returns useful values', () => { 34 | expect(colourIsLight('#ffffff')).toEqual(true); 35 | expect(colourIsLight('#000000')).not.toEqual(true); 36 | }); 37 | 38 | describe('hasLabel()', () => { 39 | const fakeIssueLabels = [ 40 | { name: 'foo' }, 41 | { name: 'fooBar' }, 42 | { name: 'something' }, 43 | ]; 44 | 45 | it('returns true for exact match', () => { 46 | expect(hasLabel(fakeIssueLabels, 'foo')).toEqual(true); 47 | }); 48 | 49 | it('returns false for partial match', () => { 50 | expect(hasLabel(fakeIssueLabels, 'thing')).toEqual(false); 51 | }); 52 | 53 | it('returns true for one of list input', () => { 54 | expect(hasLabel(fakeIssueLabels, ['foo', 'bar'])).toEqual(true); 55 | }); 56 | 57 | it('returns false for partial match of list input', () => { 58 | expect(hasLabel(fakeIssueLabels, ['thing', 'bar'])).toEqual(false); 59 | }); 60 | }); 61 | 62 | describe('hasLabelContainingString()', () => { 63 | const fakeIssueLabels = [ 64 | { name: 'foo' }, 65 | { name: 'fooBar' }, 66 | { name: 'bar' }, 67 | { name: 'baz' }, 68 | { name: 'something' }, 69 | ]; 70 | 71 | it('returns true for exact match', () => { 72 | expect(hasLabelContainingString(fakeIssueLabels, 'foo')).toEqual(true); 73 | }); 74 | 75 | it('returns true for partial match', () => { 76 | expect(hasLabelContainingString(fakeIssueLabels, 'thing')).toEqual(true); 77 | }); 78 | }); 79 | 80 | describe('getApiURL()', () => { 81 | const OLD_ENV = process.env; 82 | 83 | beforeEach(() => { 84 | jest.resetModules(); 85 | process.env = { ...OLD_ENV }; 86 | }); 87 | 88 | afterAll(() => { 89 | process.env = OLD_ENV; 90 | }); 91 | 92 | it(`should throw if path doesn't contain '/api'`, () => { 93 | function checkGetApiUrl() { 94 | getApiURL('whatever'); 95 | } 96 | expect(checkGetApiUrl).toThrow(`Path should start with '/api'`); 97 | }); 98 | 99 | it('should handle query params', () => { 100 | const result = getApiURL('/api/whatever', { param: 'foo bar' }); 101 | expect(result).toEqual('/api/whatever?param=foo%20bar'); 102 | }); 103 | 104 | it('should include host set by env var', () => { 105 | process.env.API_HOST = 'https://example.com:5000'; 106 | const result = getApiURL('/api/whatever', { param: 'foo bar' }); 107 | expect(result).toEqual( 108 | 'https://example.com:5000/api/whatever?param=foo%20bar', 109 | ); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/lib/utils/projects.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getCurrentQuarter, 3 | getNextQuarter, 4 | getPrevQuarter, 5 | parseProjectMeta, 6 | } from 'lib/utils/projects'; 7 | 8 | describe(__filename, () => { 9 | describe('getCurrentQuarter()', () => { 10 | it('should provide the quarter and year', () => { 11 | const startDate = new Date('2019', '3', '9'); 12 | const { quarter, year } = getCurrentQuarter({ _date: startDate }); 13 | expect(quarter).toEqual('Q2'); 14 | expect(year).toEqual(2019); 15 | }); 16 | }); 17 | 18 | describe('getNextQuarter()', () => { 19 | it('should get next quarter', () => { 20 | const { quarter, year } = getNextQuarter({ quarter: 'Q1', year: 2020 }); 21 | expect(quarter).toEqual('Q2'); 22 | expect(year).toEqual(2020); 23 | }); 24 | 25 | it('should rotate to q1 next year', () => { 26 | const { quarter, year } = getNextQuarter({ quarter: 'Q4', year: 2020 }); 27 | expect(quarter).toEqual('Q1'); 28 | expect(year).toEqual(2021); 29 | }); 30 | 31 | it('should return an empty object for incomplete data', () => { 32 | const { quarter, year } = getNextQuarter({ year: 2020 }); 33 | expect(quarter).toEqual(undefined); 34 | expect(year).toEqual(undefined); 35 | }); 36 | }); 37 | 38 | describe('getPrevQuarter()', () => { 39 | it('should get prev quarter', () => { 40 | const { quarter, year } = getPrevQuarter({ quarter: 'Q2', year: 2020 }); 41 | expect(quarter).toEqual('Q1'); 42 | expect(year).toEqual(2020); 43 | }); 44 | 45 | it('should rotate to q4 last year', () => { 46 | const { quarter, year } = getPrevQuarter({ quarter: 'Q1', year: 2020 }); 47 | expect(quarter).toEqual('Q4'); 48 | expect(year).toEqual(2019); 49 | }); 50 | 51 | it('should return an empty object for incomplete data', () => { 52 | const { quarter, year } = getPrevQuarter({ year: 2020 }); 53 | expect(quarter).toEqual(undefined); 54 | expect(year).toEqual(undefined); 55 | }); 56 | }); 57 | 58 | describe('parseProjectMeta()', () => { 59 | const projectMetaHTML = `
60 | Project Metadata 61 |
62 |
Engineering
63 |
@bobsilverberg
64 |
Goal Type
65 |
Primary
66 |
Size
67 |
M
68 |
69 |
`; 70 | 71 | it('should find engineer in meta', () => { 72 | const [meta] = parseProjectMeta(projectMetaHTML); 73 | expect(meta.engineers[0]).toEqual('bobsilverberg'); 74 | }); 75 | 76 | it('should find goal type in meta', () => { 77 | const [meta] = parseProjectMeta(projectMetaHTML); 78 | expect(meta.goalType).toEqual('primary'); 79 | }); 80 | 81 | it('should find goal size in meta', () => { 82 | const [meta] = parseProjectMeta(projectMetaHTML); 83 | expect(meta.size).toEqual('M'); 84 | }); 85 | 86 | it('should handle multiple engineers', () => { 87 | const projectMetaHTMLMultipleEng = `
88 | Project Metadata 89 |
90 |
Engineering
91 |
@bobsilverberg, @diox
92 |
Goal Type
93 |
Primary
94 |
Size
95 |
M
96 |
97 |
`; 98 | 99 | const [meta] = parseProjectMeta(projectMetaHTMLMultipleEng); 100 | expect(meta.engineers[0]).toEqual('bobsilverberg'); 101 | expect(meta.engineers[1]).toEqual('diox'); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /tests/lib/utils/sort.test.js: -------------------------------------------------------------------------------- 1 | import { alphaSort, dateSort, numericSort, sortData } from 'lib/utils/sort'; 2 | 3 | describe(__filename, () => { 4 | describe('dateSort()', () => { 5 | const data = [ 6 | { date: '2019-03-25T17:27:07Z' }, 7 | { date: '2019-03-20T15:51:59Z' }, 8 | ]; 9 | 10 | it('sorts dates', () => { 11 | const result = [].concat(data).sort(dateSort('date')); 12 | expect(result[0]).toEqual(data[1]); 13 | }); 14 | }); 15 | 16 | describe('numericSort()', () => { 17 | const data = [{ num: 2 }, { num: 1 }]; 18 | 19 | it('sorts numbers', () => { 20 | const result = [].concat(data).sort(numericSort('num')); 21 | expect(result[0]).toEqual(data[1]); 22 | }); 23 | }); 24 | 25 | describe('alphaSort()', () => { 26 | const data = [ 27 | { letters: 'bbcc' }, 28 | { letters: 'aabbcc' }, 29 | { letters: 'cccddd' }, 30 | { letters: 'bbcc' }, 31 | ]; 32 | 33 | it('sorts letters', () => { 34 | const result = [].concat(data).sort(alphaSort('letters')); 35 | expect(result[0]).toEqual(data[1]); 36 | }); 37 | }); 38 | 39 | describe('sortData()', () => { 40 | const data = [ 41 | { letters: 'bbcc', date: '2019-03-25T17:27:07Z' }, 42 | { letters: 'aabbcc', date: '2019-02-12T12:22:07Z' }, 43 | { letters: 'cccddd', date: '2018-12-23T07:12:07Z' }, 44 | { letters: 'bbcc', date: '2016-06-25T10:07:07Z' }, 45 | ]; 46 | const sortConfig = { 47 | letters: {}, 48 | date: { 49 | sortFunc: dateSort, 50 | }, 51 | }; 52 | 53 | it('sorts standard data', () => { 54 | const sorted = sortData({ 55 | data, 56 | columnKey: 'letters', 57 | direction: 'asc', 58 | sortConfig, 59 | }); 60 | expect(sorted[0].letters).toEqual('aabbcc'); 61 | expect(sorted[3].letters).toEqual('cccddd'); 62 | }); 63 | 64 | it('sorts standard data by specified direction', () => { 65 | const sorted = sortData({ 66 | data, 67 | columnKey: 'letters', 68 | direction: 'desc', 69 | sortConfig, 70 | }); 71 | expect(sorted[0].letters).toEqual('cccddd'); 72 | expect(sorted[3].letters).toEqual('aabbcc'); 73 | }); 74 | 75 | it('sorts dates', () => { 76 | const sorted = sortData({ 77 | data, 78 | columnKey: 'date', 79 | direction: 'asc', 80 | sortConfig, 81 | }); 82 | expect(sorted[0].date).toEqual('2016-06-25T10:07:07Z'); 83 | expect(sorted[3].date).toEqual('2019-03-25T17:27:07Z'); 84 | }); 85 | 86 | it('sorts dates by specified direction', () => { 87 | const sorted = sortData({ 88 | data, 89 | columnKey: 'date', 90 | direction: 'desc', 91 | sortConfig, 92 | }); 93 | expect(sorted[0].date).toEqual('2019-03-25T17:27:07Z'); 94 | expect(sorted[3].date).toEqual('2016-06-25T10:07:07Z'); 95 | }); 96 | 97 | it('returns data unchanged if falsey', () => { 98 | const sorted = sortData({ data: null }); 99 | expect(sorted).toEqual(null); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /tests/mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /tests/mocks/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /tests/pages/_app.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { render } from '@testing-library/react'; 7 | import MyApp from 'pages/_app'; 8 | 9 | function FakeComponent() { 10 | return
test
; 11 | } 12 | 13 | describe(__filename, () => { 14 | beforeEach(() => { 15 | React.useEffect = jest.fn(); 16 | }); 17 | 18 | it('should render the main app page', async () => { 19 | const { getAllByTestId } = render(); 20 | const wrapper = await getAllByTestId('app-wrapper'); 21 | expect(wrapper).toHaveLength(1); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/pages/api/bz-issue-counts.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getBZIssueCounts from 'pages/api/bz-issue-counts'; 5 | import bzIssueCountSingleData from 'tests/fixtures/bz-issue-count-single'; 6 | import bzIssueCountsData from 'tests/fixtures/bz-issue-counts'; 7 | 8 | describe(__filename, () => { 9 | beforeEach(() => { 10 | fetchMock.mock( 11 | 'begin:https://bugzilla.mozilla.org/rest/bug', 12 | bzIssueCountSingleData, 13 | ); 14 | }); 15 | 16 | afterEach(() => { 17 | fetchMock.restore(); 18 | }); 19 | 20 | it('should return bugzilla issue count data', async () => { 21 | const req = new MockExpressRequest({ 22 | method: 'GET', 23 | url: '/api/bugzilla-issue-counts/', 24 | }); 25 | const res = new MockExpressResponse(); 26 | await getBZIssueCounts(req, res); 27 | expect(res._getJSON()).toEqual(bzIssueCountsData); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/pages/api/bz-need-infos.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getBZNeedInfos from 'pages/api/bz-need-infos'; 5 | import bzNeedsInfoSingleData from 'tests/fixtures/bz-need-infos-single'; 6 | import bzNeedsInfoData from 'tests/fixtures/bz-need-infos'; 7 | 8 | describe(__filename, () => { 9 | const OLD_ENV = process.env; 10 | 11 | beforeEach(() => { 12 | jest.resetModules(); 13 | process.env = { ...OLD_ENV }; 14 | fetchMock.mock( 15 | 'begin:https://bugzilla.mozilla.org/rest/bug', 16 | bzNeedsInfoSingleData, 17 | ); 18 | }); 19 | 20 | afterEach(() => { 21 | process.env = OLD_ENV; 22 | fetchMock.restore(); 23 | }); 24 | 25 | it('should return bugzilla need info data', async () => { 26 | process.env.BZ_USERS = JSON.stringify({ 27 | testuser: 'testuser@example.com', 28 | testuser2: 'testuser2@example.com', 29 | }); 30 | const req = new MockExpressRequest({ 31 | method: 'GET', 32 | url: '/api/bugzilla-issue-counts/', 33 | }); 34 | const res = new MockExpressResponse(); 35 | await getBZNeedInfos(req, res); 36 | expect(res._getJSON()).toEqual(bzNeedsInfoData); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/pages/api/bz-whiteboard-tags.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getWhiteboardTags from 'pages/api/bz-whiteboard-tags'; 5 | import bzWhiteobardTagsSingleData from 'tests/fixtures/bz-whiteboard-tags-single'; 6 | import bzWhiteboardTagsData from 'tests/fixtures/bz-whiteboard-tags'; 7 | 8 | describe(__filename, () => { 9 | beforeEach(() => { 10 | fetchMock.mock( 11 | 'begin:https://bugzilla.mozilla.org/rest/bug', 12 | bzWhiteobardTagsSingleData, 13 | ); 14 | }); 15 | 16 | afterEach(() => { 17 | fetchMock.restore(); 18 | }); 19 | 20 | it('should return bugzilla whiteboard tag data', async () => { 21 | const req = new MockExpressRequest({ 22 | method: 'GET', 23 | url: '/api/bugzilla-issue-counts/', 24 | }); 25 | const res = new MockExpressResponse(); 26 | await getWhiteboardTags(req, res); 27 | expect(res._getJSON()).toEqual(bzWhiteboardTagsData); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/pages/api/gh-contrib-welcome.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getContribWelcome from 'pages/api/gh-contrib-welcome'; 5 | import contribWelcomeData from 'tests/fixtures/gh-contrib-welcome'; 6 | 7 | describe(__filename, () => { 8 | beforeEach(() => { 9 | fetchMock.mock('https://api.github.com/graphql', contribWelcomeData); 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.restore(); 14 | }); 15 | 16 | it('should return contrib welcome data', async () => { 17 | const req = new MockExpressRequest({ 18 | method: 'GET', 19 | url: '/api/gh-good-first-bugs/', 20 | }); 21 | const res = new MockExpressResponse(); 22 | await getContribWelcome(req, res); 23 | expect(res._getJSON()).toEqual(contribWelcomeData); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/pages/api/gh-good-first-bugs.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getGoodFirstBugs from 'pages/api/gh-good-first-bugs'; 5 | import goodFirstBugsData from 'tests/fixtures/gh-good-first-bugs'; 6 | 7 | describe(__filename, () => { 8 | beforeEach(() => { 9 | fetchMock.mock('https://api.github.com/graphql', goodFirstBugsData); 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.restore(); 14 | }); 15 | 16 | it('should return good first bug data', async () => { 17 | const req = new MockExpressRequest({ 18 | method: 'GET', 19 | url: '/api/gh-good-first-bugs/', 20 | }); 21 | const res = new MockExpressResponse(); 22 | await getGoodFirstBugs(req, res); 23 | expect(res._getJSON()).toEqual(goodFirstBugsData); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/pages/api/gh-issue-counts.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getIssueCounts from 'pages/api/gh-issue-counts'; 5 | import issueCountData from 'tests/fixtures/gh-issue-counts'; 6 | 7 | describe(__filename, () => { 8 | beforeEach(() => { 9 | fetchMock.mock('https://api.github.com/graphql', issueCountData); 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.restore(); 14 | }); 15 | 16 | it('should return issue count data', async () => { 17 | const req = new MockExpressRequest({ 18 | method: 'GET', 19 | url: '/api/gh-issue-counts/', 20 | }); 21 | const res = new MockExpressResponse(); 22 | await getIssueCounts(req, res); 23 | expect(res._getJSON()).toEqual(issueCountData); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/pages/api/gh-maybe-good-first-bugs.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getMaybeGoodFirstBugs from 'pages/api/gh-maybe-good-first-bugs'; 5 | import maybeGoodFirstBugsData from 'tests/fixtures/gh-maybe-good-first-bugs'; 6 | 7 | describe(__filename, () => { 8 | beforeEach(() => { 9 | fetchMock.mock('https://api.github.com/graphql', maybeGoodFirstBugsData); 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.restore(); 14 | }); 15 | 16 | it('should return maybe good first bug data', async () => { 17 | const req = new MockExpressRequest({ 18 | method: 'GET', 19 | url: '/api/gh-maybe-good-first-bugs/', 20 | }); 21 | const res = new MockExpressResponse(); 22 | await getMaybeGoodFirstBugs(req, res); 23 | expect(res._getJSON()).toEqual(maybeGoodFirstBugsData); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/pages/api/gh-milestone-issues.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getMilestones from 'pages/api/gh-milestone-issues'; 5 | import milestoneData from 'tests/fixtures/gh-milestone-issues'; 6 | 7 | describe(__filename, () => { 8 | beforeEach(() => { 9 | fetchMock.once('https://api.github.com/graphql', milestoneData); 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.restore(); 14 | }); 15 | 16 | it('should return a 400 for an invalid milestone', async () => { 17 | const req = new MockExpressRequest({ 18 | method: 'GET', 19 | url: '/api/milestone-issues/?milestone=2018-13-32', 20 | query: { 21 | milestone: '2018-13-32', 22 | }, 23 | }); 24 | const res = new MockExpressResponse(); 25 | await getMilestones(req, res); 26 | expect(res.statusCode).toEqual(400); 27 | expect(res._getJSON()).toEqual({ error: 'Incorrect milestone format' }); 28 | }); 29 | 30 | it('should return milestone data', async () => { 31 | const req = new MockExpressRequest({ 32 | method: 'GET', 33 | url: '/api/milestone-issues/?milestone=2019-05-09', 34 | query: { 35 | milestone: '2019-05-09', 36 | }, 37 | }); 38 | const res = new MockExpressResponse(); 39 | await getMilestones(req, res); 40 | expect(res._getJSON()).toEqual(milestoneData); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/pages/api/gh-projects.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getProjects from 'pages/api/gh-projects'; 5 | import projectData from 'tests/fixtures/gh-projects'; 6 | 7 | describe(__filename, () => { 8 | beforeEach(() => { 9 | fetchMock.once('https://api.github.com/graphql', projectData); 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.restore(); 14 | }); 15 | 16 | it('should return project data', async () => { 17 | const req = new MockExpressRequest({ 18 | method: 'GET', 19 | url: '/api/gh-projects/?year=2018&quarter=Q3', 20 | query: { 21 | year: '2018', 22 | quarter: 'Q3', 23 | }, 24 | }); 25 | const res = new MockExpressResponse(); 26 | await getProjects(req, res); 27 | expect(res._getJSON()).toEqual(projectData); 28 | }); 29 | 30 | it('should return a 400 for an invalid year', async () => { 31 | const req = new MockExpressRequest({ 32 | method: 'GET', 33 | url: '/api/gh-projects/?year=whatever&quarter=Q3', 34 | query: { 35 | year: 'whatever', 36 | quarter: 'Q3', 37 | }, 38 | }); 39 | 40 | const res = new MockExpressResponse(); 41 | await getProjects(req, res); 42 | expect(res.statusCode).toEqual(400); 43 | expect(res._getJSON()).toEqual({ error: 'Incorrect year format' }); 44 | }); 45 | 46 | it('should return a 400 for an invalid quarter', async () => { 47 | const req = new MockExpressRequest({ 48 | method: 'GET', 49 | url: '/api/gh-projects/?year=2018&quarter=whatever', 50 | query: { 51 | year: '2018', 52 | quarter: 'whatever', 53 | }, 54 | }); 55 | const res = new MockExpressResponse(); 56 | await getProjects(req, res); 57 | expect(res.statusCode).toEqual(400); 58 | expect(res._getJSON()).toEqual({ error: 'Incorrect quarter format' }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/pages/api/gh-team.test.js: -------------------------------------------------------------------------------- 1 | import fetchMock from 'fetch-mock'; 2 | import MockExpressRequest from 'mock-express-request'; 3 | import MockExpressResponse from 'mock-express-response'; 4 | import getTeam from 'pages/api/gh-team'; 5 | import teamData from 'tests/fixtures/gh-team'; 6 | 7 | describe(__filename, () => { 8 | beforeEach(() => { 9 | fetchMock.mock('https://api.github.com/graphql', teamData); 10 | }); 11 | 12 | afterEach(() => { 13 | fetchMock.restore(); 14 | fetchMock.reset(); 15 | }); 16 | 17 | it('should return team data', async () => { 18 | const req = new MockExpressRequest({ 19 | method: 'GET', 20 | url: '/api/gh-team/', 21 | }); 22 | const res = new MockExpressResponse(); 23 | await getTeam(req, res); 24 | expect(res._getJSON()).toEqual(teamData); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/pages/contrib/contrib-welcome.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import fetchMock from 'fetch-mock'; 6 | import mockRouter from 'next-router-mock'; 7 | import ghContribWelcomeData from 'tests/fixtures/gh-contrib-welcome'; 8 | import { cleanup, render } from '@testing-library/react'; 9 | import ContribWelcome, { 10 | getServerSideProps, 11 | } from 'pages/contrib/contrib-welcome'; 12 | 13 | // eslint-disable-next-line global-require 14 | jest.mock('next/router', () => require('next-router-mock')); 15 | 16 | describe(__filename, () => { 17 | beforeEach(() => { 18 | fetchMock.mock(/\/api\/gh-contrib-welcome\//, ghContribWelcomeData); 19 | }); 20 | 21 | afterEach(() => { 22 | fetchMock.restore(); 23 | cleanup(); 24 | }); 25 | 26 | it('should render the Contrib Welcome Page', async () => { 27 | mockRouter.setCurrentUrl( 28 | '/contrib/contrib-welcome/?dir=asc&sort=updatedAt', 29 | ); 30 | const { props } = await getServerSideProps(); 31 | const { findByRole } = render(); 32 | const main = await findByRole('main'); 33 | expect(main).toHaveClass('container'); 34 | }); 35 | 36 | it('should fetch data via getServerSideProps', async () => { 37 | const { props: serverProps } = await getServerSideProps(); 38 | expect( 39 | serverProps.contribWelcomeData.data.contrib_welcome.results.length, 40 | ).toEqual(3); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/pages/contrib/good-first-bugs.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import fetchMock from 'fetch-mock'; 6 | import mockRouter from 'next-router-mock'; 7 | import { cleanup, render } from '@testing-library/react'; 8 | import ghGoodFirstBugsData from 'tests/fixtures/gh-good-first-bugs'; 9 | import GoodFirstBugs, { 10 | getServerSideProps, 11 | } from 'pages/contrib/good-first-bugs'; 12 | 13 | // eslint-disable-next-line global-require 14 | jest.mock('next/router', () => require('next-router-mock')); 15 | 16 | describe(__filename, () => { 17 | beforeEach(() => { 18 | fetchMock.mock(/\/api\/gh-good-first-bugs\//, ghGoodFirstBugsData); 19 | }); 20 | 21 | afterEach(() => { 22 | fetchMock.restore(); 23 | cleanup(); 24 | }); 25 | 26 | it('should render the Good First Bugs Page', async () => { 27 | mockRouter.setCurrentUrl('/contrib/good-first-bugs?dir=asc&sort=updatedAt'); 28 | const { props } = await getServerSideProps(); 29 | const { findByRole } = render(); 30 | const main = await findByRole('main'); 31 | expect(main).toHaveClass('container'); 32 | }); 33 | 34 | it('should fetch data via getServerSideProps', async () => { 35 | const { props: serverProps } = await getServerSideProps(); 36 | expect( 37 | serverProps.goodFirstBugsData.data.good_first_bugs.results.length, 38 | ).toEqual(3); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/pages/contrib/maybe-good-first-bugs.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import fetchMock from 'fetch-mock'; 6 | import mockRouter from 'next-router-mock'; 7 | import ghMaybeGoodFirstBugsData from 'tests/fixtures/gh-maybe-good-first-bugs'; 8 | import { cleanup, render } from '@testing-library/react'; 9 | import MaybeGoodFirstBugs, { 10 | getServerSideProps, 11 | } from 'pages/contrib/maybe-good-first-bugs'; 12 | 13 | // eslint-disable-next-line global-require 14 | jest.mock('next/router', () => require('next-router-mock')); 15 | 16 | describe(__filename, () => { 17 | beforeEach(() => { 18 | fetchMock.mock( 19 | /\/api\/gh-maybe-good-first-bugs\//, 20 | ghMaybeGoodFirstBugsData, 21 | ); 22 | }); 23 | 24 | afterEach(() => { 25 | fetchMock.restore(); 26 | cleanup(); 27 | }); 28 | 29 | it('should render the Maybe Good First Bugs Page', async () => { 30 | mockRouter.setCurrentUrl( 31 | '/contrib/maybe-good-first-bugs/?dir=asc&sort=updatedAt', 32 | ); 33 | const { props } = await getServerSideProps(); 34 | const { findByRole } = render(); 35 | const main = await findByRole('main'); 36 | expect(main).toHaveClass('container'); 37 | }); 38 | 39 | it('should fetch data via getServerSideProps', async () => { 40 | const { props: serverProps } = await getServerSideProps(); 41 | expect( 42 | serverProps.maybeGoodFirstBugsData.data.maybe_good_first_bugs.results 43 | .length, 44 | ).toEqual(1); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/pages/dashboards/amo.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { cleanup, render } from '@testing-library/react'; 6 | import mockRouter from 'next-router-mock'; 7 | import fetchMock from 'fetch-mock'; 8 | import ghIssueCountsData from 'tests/fixtures/gh-issue-counts'; 9 | import DashboardAMO, { getServerSideProps } from 'pages/dashboards/amo'; 10 | 11 | // eslint-disable-next-line global-require 12 | jest.mock('next/router', () => require('next-router-mock')); 13 | 14 | describe(__filename, () => { 15 | beforeEach(() => { 16 | fetchMock.mock(/\/api\/gh-issue-counts\//, ghIssueCountsData); 17 | }); 18 | 19 | afterEach(() => { 20 | fetchMock.restore(); 21 | cleanup(); 22 | }); 23 | 24 | it('should render the AMO dashboard', async () => { 25 | mockRouter.setCurrentUrl('/dashboards/amo/'); 26 | const { props } = await getServerSideProps(); 27 | const { findByRole, findAllByText } = render(); 28 | const main = await findByRole('main'); 29 | expect(main).toHaveClass('container'); 30 | 31 | // All the dashgroups. 32 | const cardGroups = await findAllByText(/.*?/, { selector: '.card-grp' }); 33 | expect(cardGroups).toHaveLength(6); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/pages/dashboards/webext.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { cleanup, render } from '@testing-library/react'; 6 | import mockRouter from 'next-router-mock'; 7 | import fetchMock from 'fetch-mock'; 8 | import bzIssueCountsData from 'tests/fixtures/bz-issue-counts'; 9 | import bzNeedInfoData from 'tests/fixtures/bz-need-infos'; 10 | import bzWhiteboardTagData from 'tests/fixtures/bz-whiteboard-tags'; 11 | import DashboardWE, { getServerSideProps } from 'pages/dashboards/webext'; 12 | 13 | // eslint-disable-next-line global-require 14 | jest.mock('next/router', () => require('next-router-mock')); 15 | 16 | describe(__filename, () => { 17 | beforeEach(() => { 18 | fetchMock.mock(/\/api\/bz-issue-counts\//, bzIssueCountsData); 19 | fetchMock.mock(/\/api\/bz-need-infos\//, bzNeedInfoData); 20 | fetchMock.mock(/\/api\/bz-whiteboard-tags\//, bzWhiteboardTagData); 21 | }); 22 | 23 | afterEach(() => { 24 | fetchMock.restore(); 25 | cleanup(); 26 | }); 27 | 28 | it('should render the AMO dashboard', async () => { 29 | mockRouter.setCurrentUrl('/dashboards/webext/'); 30 | const { props } = await getServerSideProps(); 31 | const { findByRole, findAllByText } = render(); 32 | const main = await findByRole('main'); 33 | expect(main).toHaveClass('container'); 34 | 35 | // All the dashgroups. 36 | const cardGroups = await findAllByText(/.*?/, { selector: '.card-grp' }); 37 | expect(cardGroups).toHaveLength(4); 38 | // The needinfo group. 39 | const needInfos = await findAllByText(/.*?/, { 40 | selector: '.card-grp.needinfos', 41 | }); 42 | expect(needInfos).toHaveLength(1); 43 | // The whiteboard group. 44 | const whiteboardTags = await findAllByText(/.*?/, { 45 | selector: '.card-grp.whiteboardtags', 46 | }); 47 | expect(whiteboardTags).toHaveLength(1); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/pages/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render } from '@testing-library/react'; 6 | import Home from 'pages/index'; 7 | 8 | describe(__filename, () => { 9 | it('should render the home page', async () => { 10 | const { findAllByRole, findAllByText } = render(); 11 | const container = await findAllByRole('main'); 12 | expect(container).toHaveLength(1); 13 | 14 | const cardGroups = await findAllByText(/.*?/, { selector: '.card' }); 15 | expect(cardGroups).toHaveLength(4); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/pages/milestones/[milestone].test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import fetchMock from 'fetch-mock'; 6 | import mockRouter from 'next-router-mock'; 7 | import ghMilestoneIssuesData from 'tests/fixtures/gh-milestone-issues'; 8 | import { cleanup, render } from '@testing-library/react'; 9 | import Milestones, { getServerSideProps } from 'pages/milestones/[milestone]'; 10 | 11 | // eslint-disable-next-line global-require 12 | jest.mock('next/router', () => require('next-router-mock')); 13 | 14 | describe(__filename, () => { 15 | let fakeProps; 16 | 17 | beforeEach(() => { 18 | fetchMock.mock(/\/api\/gh-milestone-issues\//, ghMilestoneIssuesData); 19 | fakeProps = { 20 | params: { 21 | milestone: '2021-01-21', 22 | }, 23 | }; 24 | }); 25 | 26 | afterEach(() => { 27 | fetchMock.restore(); 28 | cleanup(); 29 | }); 30 | 31 | it('should render the Milestone Page', async () => { 32 | mockRouter.setCurrentUrl( 33 | '/milestones/2021-01-21/?milestone=2021-01-21&dir=asc&sort=assignee', 34 | ); 35 | const { props } = await getServerSideProps(fakeProps); 36 | const { findByRole } = render(); 37 | const main = await findByRole('main'); 38 | expect(main).toHaveClass('container'); 39 | }); 40 | 41 | it('should fetch data via getServerSideProps', async () => { 42 | const { props: serverProps } = await getServerSideProps(fakeProps); 43 | expect(serverProps.milestoneIssues.length).toEqual(13); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/pages/milestones/index.test.js: -------------------------------------------------------------------------------- 1 | import Page, { getServerSideProps } from 'pages/milestones/index'; 2 | 3 | describe(__filename, () => { 4 | it("should not render anything as it's a redirect", () => { 5 | expect(Page()).toEqual(null); 6 | }); 7 | 8 | it('should return redirect data', async () => { 9 | const fakeProps = { 10 | query: { 11 | foo: 'bar', 12 | }, 13 | }; 14 | const { redirect } = await getServerSideProps(fakeProps); 15 | expect(redirect.permanent).toEqual(false); 16 | expect(redirect.destination).toMatch(/^\/milestones\//); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /tests/pages/milestones/latest.test.js: -------------------------------------------------------------------------------- 1 | import Page, { getServerSideProps } from 'pages/milestones/latest'; 2 | 3 | describe(__filename, () => { 4 | it("should not render anything as it's a redirect", () => { 5 | expect(Page()).toEqual(null); 6 | }); 7 | 8 | it('should return redirect data', async () => { 9 | const fakeProps = { 10 | query: { 11 | foo: 'bar', 12 | }, 13 | }; 14 | const { redirect } = await getServerSideProps(fakeProps); 15 | expect(redirect.permanent).toEqual(false); 16 | expect(redirect.destination).toMatch(/^\/milestones\//); 17 | }); 18 | 19 | it('should set default query params if not defined', async () => { 20 | const fakeProps = { 21 | query: { 22 | foo: 'bar', 23 | }, 24 | }; 25 | const { redirect } = await getServerSideProps(fakeProps); 26 | expect(redirect.permanent).toEqual(false); 27 | expect(redirect.destination).toMatch(/^\/milestones\//); 28 | expect(redirect.destination).toMatch(/sort=assignee/); 29 | expect(redirect.destination).toMatch(/dir=asc/); 30 | }); 31 | 32 | it('should not override supplied dir query param if defined', async () => { 33 | const fakeProps = { 34 | query: { 35 | foo: 'bar', 36 | dir: 'desc', 37 | }, 38 | }; 39 | const { redirect } = await getServerSideProps(fakeProps); 40 | expect(redirect.permanent).toEqual(false); 41 | expect(redirect.destination).toMatch(/^\/milestones\//); 42 | expect(redirect.destination).toMatch(/sort=assignee/); 43 | expect(redirect.destination).toMatch(/dir=desc/); 44 | }); 45 | 46 | it('should not override supplied sort query params if defined', async () => { 47 | const fakeProps = { 48 | query: { 49 | foo: 'bar', 50 | sort: 'whatever', 51 | }, 52 | }; 53 | const { redirect } = await getServerSideProps(fakeProps); 54 | expect(redirect.permanent).toEqual(false); 55 | expect(redirect.destination).toMatch(/^\/milestones\//); 56 | expect(redirect.destination).toMatch(/sort=whatever/); 57 | expect(redirect.destination).toMatch(/dir=asc/); 58 | }); 59 | 60 | it('should still redir with no query object', async () => { 61 | const fakeProps = {}; 62 | const { redirect } = await getServerSideProps(fakeProps); 63 | expect(redirect.permanent).toEqual(false); 64 | expect(redirect.destination).toMatch(/^\/milestones\//); 65 | expect(redirect.destination).toMatch(/sort=assignee/); 66 | expect(redirect.destination).toMatch(/dir=asc/); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/pages/projects/[year]/[quarter].test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import fetchMock from 'fetch-mock'; 6 | import mockRouter from 'next-router-mock'; 7 | import ghProjectsData from 'tests/fixtures/gh-projects'; 8 | import ghTeamData from 'tests/fixtures/gh-team'; 9 | import { cleanup, render } from '@testing-library/react'; 10 | import Projects, { getServerSideProps } from 'pages/projects/[year]/[quarter]'; 11 | 12 | // eslint-disable-next-line global-require 13 | jest.mock('next/router', () => require('next-router-mock')); 14 | 15 | describe(__filename, () => { 16 | let fakeProps; 17 | 18 | beforeEach(() => { 19 | fetchMock.mock(/\/api\/gh-projects\//, ghProjectsData); 20 | fetchMock.mock(/\/api\/gh-team\//, ghTeamData); 21 | fakeProps = { 22 | params: { 23 | year: '2021', 24 | quarter: 'Q1', 25 | }, 26 | }; 27 | }); 28 | 29 | afterEach(() => { 30 | fetchMock.restore(); 31 | cleanup(); 32 | }); 33 | 34 | it('should render the Projects Page', async () => { 35 | mockRouter.setCurrentUrl('/projects/2021/Q1/?year=2021&quarter=Q1'); 36 | const { props } = await getServerSideProps(fakeProps); 37 | const { findByRole } = render(); 38 | const main = await findByRole('main'); 39 | expect(main).toHaveClass('container'); 40 | }); 41 | 42 | it('should fetch data via getServerSideProps', async () => { 43 | const { props: serverProps } = await getServerSideProps(fakeProps); 44 | expect( 45 | serverProps.projects.data.organization.projects.nodes.length, 46 | ).toEqual(4); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/pages/projects/index.test.js: -------------------------------------------------------------------------------- 1 | import Page, { getServerSideProps } from 'pages/projects/index'; 2 | 3 | describe(__filename, () => { 4 | it("should not render anything as it's a redirect", () => { 5 | expect(Page()).toEqual(null); 6 | }); 7 | 8 | it('should return redirect data and pass on query params', async () => { 9 | const fakeProps = { 10 | query: { 11 | foo: 'bar', 12 | }, 13 | }; 14 | const { redirect } = await getServerSideProps(fakeProps); 15 | expect(redirect.permanent).toEqual(false); 16 | expect(redirect.destination).toMatch(/^\/projects\//); 17 | expect(redirect.destination).toMatch(/foo=bar/); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/pages/projects/latest.test.js: -------------------------------------------------------------------------------- 1 | import Page, { getServerSideProps } from 'pages/projects/latest'; 2 | 3 | describe(__filename, () => { 4 | it("should not render anything as it's a redirect", () => { 5 | expect(Page()).toEqual(null); 6 | }); 7 | 8 | it('should return redirect data', async () => { 9 | const fakeProps = { 10 | query: { 11 | foo: 'bar', 12 | }, 13 | }; 14 | const { redirect } = await getServerSideProps(fakeProps); 15 | expect(redirect.permanent).toEqual(false); 16 | expect(redirect.destination).toMatch(/^\/projects\//); 17 | }); 18 | }); 19 | --------------------------------------------------------------------------------