* Initial port to next.js

* Re-instate basic test coverage for contrib pages

* Remove unnecessary style

* Update Procfile

* Adopt test directory mirroring to avoid issues with test files in pages

* Try start-nginx-solo

* Attempt to configure next under nginx

* Use custom server

* Properly specify socket file

* Get rid of API_ROOT in favour of env vars

* Add .env defaults file

* Allow .env.example to be committed

* Fix bug with not redefining API urls based on new params on client-side

* Run prettier

* Update next and fix tests

* Surface API errors on server

* Fix bug caused by Next.js incorrectly handling period delimted placeholders

* Update dashboards adding stockwell and p3 and general clean-ups

* Re-instate redirect http->https

* Restore fragement fetcher script and update to use next/env lib

* Add comment re: hashing for ghapi

* Restore stylelint

* Re-instate contrib css

* Workaround ENOMEM issues running on circle

* Remove unnecessary React imports

* Use bob's jsx-a11y config

* Condense waitFor

* fix eslint config

* Fix lint

* Remove null default

* Add a robots.txt file

* Remove mock clearing as it's automatic

* Update nginx config with includes + caching

* Try caching to /tmp dir on heroku

* Fix missing React import

* Enable nginx caching for public/static files served under /static

* Fix nginx config

* Use JSON.stringify

* Use eslint-plugin-amo and fix lint

* Remove lint options that no longer conflict with prettier

* toBe -> toEqual

* Better descriptions for redirect tests

* Stockwell -> STW

* Add NEXT_TELEMETRY_DISABLED=1 to .env

* Remove commented console statement

* Run prettier

* Move test directories

* Dashboard counts for AMO should be 1 or higher

* Don't override constribution assignment

* Consolidate assignment

* Add space after 'Updated'

* Add nprogress

* Re-instate inline styles in CSP

* Fix links on homepage
This commit is contained in:
Stuart Colville 2021-02-11 11:33:41 +00:00 коммит произвёл GitHub
Родитель 3379275509
Коммит f22ddf49b0
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
156 изменённых файлов: 7874 добавлений и 12142 удалений

3
.babelrc Normal file
Просмотреть файл

@ -0,0 +1,3 @@
{
"presets": ["next/babel"]
}

8
.env Normal file
Просмотреть файл

@ -0,0 +1,8 @@
# DO NOT INCLUDE SECRETS IN THIS FILE!
# This file is committed and should contain defaults only.
HOSTNAME=localhost
PORT=3000
API_HOST=http://$HOSTNAME:$PORT
NEXT_TELEMETRY_DISABLED=1

Просмотреть файл

@ -1,6 +1,41 @@
{
"extends": "react-app",
"extends": [
"amo",
"plugin:amo/recommended"
],
"env": {
"jest/globals": true
},
"parser": "babel-eslint",
"plugins": [
"jest"
],
"globals": {
"fetch": true
},
"rules": {
"react-hooks/rules-of-hooks": "warn",
// These rules are not compatible with Prettier.
"indent": "off",
"operator-linebreak": "off",
"react/jsx-one-expression-per-line": "off",
// Modify rules.
"jsx-a11y/anchor-is-valid": [
"error",
{
"components": ["Link"],
"specialLink": ["hrefLeft", "hrefRight"],
"aspects": ["invalidHref", "preferButton"]
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"import/no-extraneous-dependencies": "off",
"import/no-unresolved": "off",
"import/extensions": "off",
"operator-assignment": "off",
"no-param-reassign": "off",
"no-nested-ternary": "off",
// This can be off as Next imports React.
"react/react-in-jsx-scope": "off",
}
}

26
.gitignore поставляемый
Просмотреть файл

@ -1,24 +1,36 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
.coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.example
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*.local
# vercel
.vercel

Просмотреть файл

@ -7,7 +7,7 @@
/config/
/coverage/
Procfile
# white-list files we want to process
# Allow files we want to process
!*.js
!*.md
!*.scss

Просмотреть файл

@ -2,5 +2,6 @@
"arrowParens": "always",
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never"
"proseWrap": "never",
"quoteProps": "preserve"
}

Просмотреть файл

@ -1 +1 @@
web: bin/start-nginx yarn start-server
web: bin/start-nginx node bin/server.js

Просмотреть файл

@ -1,9 +1,13 @@
// Magically require any env variables defined in a local .env file.
require('dotenv').config();
const path = require('path');
const fs = require('fs');
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');
const { loadEnvConfig } = require('@next/env');
// Require env variables.
const dev = process.env.NODE_ENV !== 'production';
loadEnvConfig(path.join(__dirname, '../'), dev);
const headers = { 'Content-Type': 'application/json' };
if (process.env.GH_TOKEN) {
@ -14,7 +18,7 @@ if (process.env.GH_TOKEN) {
fetch(`https://api.github.com/graphql`, {
method: 'POST',
headers: headers,
headers,
body: JSON.stringify({
variables: {},
query: `
@ -40,12 +44,14 @@ fetch(`https://api.github.com/graphql`, {
);
result.data.__schema.types = filteredData;
fs.writeFile(
path.join(__dirname, '../src/server/fragmentTypes.json'),
path.join(__dirname, '../lib/fragmentTypes.json'),
JSON.stringify(result.data),
(err) => {
if (err) {
// eslint-disable-next-line no-console
console.error('Error writing fragmentTypes file', err);
} else {
// eslint-disable-next-line no-console
console.log('Fragment types successfully extracted!');
}
},

23
bin/server.js Normal file
Просмотреть файл

@ -0,0 +1,23 @@
const { createServer } = require('http');
const { parse } = require('url');
const fs = require('fs');
const next = require('next');
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
}).listen('/tmp/nginx.socket', (err) => {
if (err) {
throw err;
}
// eslint-disable-next-line no-console
console.log('Add-ons PM NextJS Server is running');
fs.openSync('/tmp/app-initialized', 'w');
});
});

Просмотреть файл

@ -1,45 +1,38 @@
import React from 'react';
import { oneLineTrim } from 'common-tags';
import DashCount from './DashCount';
export default function AMODashCount(props) {
const repo = props.repo.replace(/_/g, '-');
let warning = false;
let warningLimit;
let issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen`;
if (props.title.includes('total open issues')) {
}
if (props.title.includes('untriaged')) {
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20-label%3A%22priority%3A%20p1%22%20
-label%3A%22priority%3A%20p2%22%20-label%3A%22priority%3A%20p3%22%20
-label%3A%22priority%3A%20p4%22%20-label%3A%22priority%3A%20p5%22`;
if (props.count > 15) {
warning = true;
}
warningLimit = 15;
}
if (props.title.includes('p1')) {
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:%20p1%22`;
if (props.count > 0) {
warning = true;
}
warningLimit = 1;
}
if (props.title.includes('p2')) {
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:%20p2%22`;
if (props.count > 0) {
warning = true;
}
warningLimit = 1;
}
if (props.title.includes('p3')) {
issuesLink = oneLineTrim`https://github.com/mozilla/${repo}/issues?
utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22priority:%20p3%22`;
warningLimit = undefined;
}
if (props.title.includes('open prs')) {
issuesLink = `https://github.com/mozilla/${repo}/pulls?q=is%3Apr+is%3Aopen`;
if (props.count > 10) {
warning = true;
}
warningLimit = 10;
}
return (
@ -47,7 +40,7 @@ export default function AMODashCount(props) {
title={props.title}
key={props.repo + props.title}
link={issuesLink}
warning={warning}
warningLimit={warningLimit}
count={props.count}
/>
);

Просмотреть файл

@ -1,4 +1,3 @@
import React from 'react';
import AMODashCount from './AMODashCount';
import DashCountGroup from './DashCountGroup';
@ -23,8 +22,8 @@ export default function AMODashCountGroup(props) {
}),
);
Object.keys(issueCounts).forEach((count, index) => {
const totalCount = issueCounts[count].totalCount;
Object.keys(issueCounts).forEach((count) => {
const { totalCount } = issueCounts[count];
const title = count.replace('_', ' ');
if (
!count.startsWith('__') &&

36
components/ActiveLink.js Normal file
Просмотреть файл

@ -0,0 +1,36 @@
import { useRouter } from 'next/router';
import PropTypes from 'prop-types';
import Link from 'next/link';
import React, { Children } from 'react';
const ActiveLink = ({
children,
activeClassName = 'active',
...props
} = {}) => {
const { asPath } = useRouter();
const child = Children.only(children);
const childClassName = child.props.className || '';
// pages/index.js will be matched via props.href
// pages/about.js will be matched via props.href
// pages/[slug].js will be matched via props.as
const className =
asPath === props.href || asPath === props.as
? `${childClassName} ${activeClassName}`.trim()
: childClassName;
return (
<Link {...props}>
{React.cloneElement(child, {
className: className || null,
})}
</Link>
);
};
ActiveLink.propTypes = {
activeClassName: PropTypes.string.isRequired,
};
export default ActiveLink;

191
components/Contrib.js Normal file
Просмотреть файл

@ -0,0 +1,191 @@
import { Helmet } from 'react-helmet';
import { useRouter } from 'next/router';
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
import TimeAgo from 'react-timeago';
import { AlertIcon, LinkIcon } from '@primer/octicons-react';
import { dateSort, numericSort, sortData } from 'lib/utils/sort';
import YesNoBool from 'components/YesNoBool';
import HeaderLink from 'components/HeaderLink';
import ActiveLink from 'components/ActiveLink';
// These views display assignment columns.
const sortConfig = {
priority: {},
title: {},
repo: {},
assigned: {
sortFunc: numericSort,
},
mentorAssigned: {
sortFunc: numericSort,
},
updatedAt: {
sortFunc: dateSort,
},
};
function renderRows({ data, hasAssignments }) {
const rows = [];
const colSpan = 6;
if (!data) {
return (
<tr>
<td colSpan={colSpan}>Loading...</td>
</tr>
);
}
if (data.length === 0) {
return (
<tr>
<td colSpan={colSpan}>
<div className="not-found">
<p>
No issues found! Time to deploy the team to find some quality
bugs!
</p>
</div>
</td>
</tr>
);
}
for (let i = 0; i < data.length; i++) {
const issue = data[i];
rows.push(
<tr key={`issue-${i}`}>
<td>
<span className={issue.priority || 'unprioritized'}>
{issue.priority ? issue.priority.toUpperCase() : <AlertIcon />}
</span>
</td>
<td>
<a href={issue.url} target="_blank" rel="noopener noreferrer">
<strong>#{issue.number}:</strong> {issue.title}{' '}
<LinkIcon verticalAlign="middle" />
</a>
</td>
<td>{issue.repository.name.replace('addons-', '')}</td>
{hasAssignments ? (
<td className="centered">
<YesNoBool
bool={issue.assigned}
extraClasses={{
yes: ['contributor'],
no: ['contributor', 'not-assigned'],
}}
/>
</td>
) : null}
{hasAssignments ? (
<td className="centered">
<YesNoBool
bool={issue.mentorAssigned}
extraClasses={{
yes: ['mentor'],
no: ['mentor', 'not-assigned'],
}}
/>
</td>
) : null}
<td>
<TimeAgo date={issue.updatedAt} />
</td>
</tr>,
);
}
return rows;
}
const Contrib = (props) => {
const router = useRouter();
const { dir, sort } = router.query;
const { contribData, hasAssignments } = props;
let data = contribData;
if (sort) {
data = sortData({ data, columnKey: sort, direction: dir, sortConfig });
}
return (
<div className="Contrib">
<Helmet>
<title>Contributions</title>
</Helmet>
<Navbar
variant="muted"
bg="light"
className="shadow-sm d-flex justify-content-between"
sticky="top"
>
<Nav variant="pills">
<Nav.Item>
<ActiveLink
activeClassName="active"
href="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
passHref
>
<Nav.Link eventKey="mgfb">Maybe Good First Bugs</Nav.Link>
</ActiveLink>
</Nav.Item>
<Nav.Item>
<ActiveLink
activeClassName="active"
href="/contrib/good-first-bugs/?dir=desc&sort=updatedAt"
passHref
>
<Nav.Link eventKey="gfb">Good First Bugs</Nav.Link>
</ActiveLink>
</Nav.Item>
<Nav.Item>
<ActiveLink
activeClassName="active"
href="/contrib/contrib-welcome/?dir=desc&sort=updatedAt"
passHref
>
<Nav.Link eventKey="cw">Contrib Welcome</Nav.Link>
</ActiveLink>
</Nav.Item>
</Nav>
</Navbar>
<Container as="main" bg="light">
<Table responsive hover>
<thead>
<tr>
<th>
<HeaderLink columnKey="priority" linkText="Priority" />
</th>
<th>
<HeaderLink columnKey="title" linkText="Issue" />
</th>
<th className="repo">
<HeaderLink columnKey="repo" linkText="Repo" />
</th>
{hasAssignments ? (
<th>
<HeaderLink columnKey="assigned" linkText="Assigned?" />
</th>
) : null}
{hasAssignments ? (
<th>
<HeaderLink
columnKey="mentorAssigned"
linkText="Has Mentor?"
/>
</th>
) : null}
<th className="last-updated">
<HeaderLink columnKey="updatedAt" linkText="Last Update" />
</th>
</tr>
</thead>
<tbody>{renderRows({ data, hasAssignments })}</tbody>
</Table>
</Container>
</div>
);
};
export default Contrib;

9
components/DashBlank.js Normal file
Просмотреть файл

@ -0,0 +1,9 @@
import { Card } from 'react-bootstrap';
export default function DashBlank() {
return (
<Card bg="dark" text="white">
<Card.Body />
</Card>
);
}

Просмотреть файл

@ -1,11 +1,12 @@
import React from 'react';
import classNames from 'classnames';
import { Card } from 'react-bootstrap';
import './DashCount.scss';
export default function DashCount(props) {
let extraTitle = '';
if (typeof props.warningLimit !== 'undefined') {
extraTitle = ` (Warning Threshold: count >= ${props.warningLimit})`;
}
return (
<Card
bg="dark"
@ -14,20 +15,30 @@ export default function DashCount(props) {
href={props.link}
target="_blank"
rel="noopener noreferrer"
title={`${props.title}${extraTitle}`}
>
<Card.Header>{props.title.toUpperCase()}</Card.Header>
<Card.Body>
<div
data-testid="dashcount-svg-wrapper"
className={classNames({
outer: true,
warning: props.warning,
warning:
typeof props.warningLimit !== 'undefined' &&
props.count >= props.warningLimit,
total: props.title.includes('total open'),
})}
>
<svg preserveAspectRatio="xMinYMin meet">
<g>
<circle r="30%" cx="50%" cy="50%" className="circle-back" />
<text x="50%" y="50%" textAnchor="middle" dy="0.3em">
<text
x="50%"
y="50%"
textAnchor="middle"
dy="0.3em"
data-testid="dashcount-count"
>
{props.count}
</text>
</g>

Просмотреть файл

@ -1,4 +1,3 @@
import React from 'react';
import { Card } from 'react-bootstrap';
export default function DashCountGroup(props) {
@ -6,11 +5,16 @@ export default function DashCountGroup(props) {
<div
className={props.className ? `card-grp ${props.className}` : 'card-grp'}
key={props.key}
data-testid="dashcountgroup"
>
<Card bg="dark" text="white" className="repo-card">
<Card.Header>{props.title}</Card.Header>
<Card bg="dark" text="white" className="title-card">
<Card.Header data-testid="dashcountgroup-title">
{props.title}
</Card.Header>
<Card.Body>
<Card.Text>{props.description}</Card.Text>
<Card.Text data-testid="dashcountgroup-desc">
{props.description}
</Card.Text>
</Card.Body>
</Card>
{props.children}

24
components/Engineer.js Normal file
Просмотреть файл

@ -0,0 +1,24 @@
import Link from 'next/link';
import { Image } from 'react-bootstrap';
export default function Engineer(props) {
const { member, year, quarter } = props;
return (
<Link
href={`/projects/${year}/${quarter}/?engineer=${encodeURIComponent(
member.login.toLowerCase(),
)}`}
>
<a>
<Image
src={member.avatarUrl}
title={member.login}
alt={member.login}
roundedCircle
className="float-right eng-image"
height="35"
/>
</a>
</Link>
);
}

29
components/HeaderLink.js Normal file
Просмотреть файл

@ -0,0 +1,29 @@
import { useRouter } from 'next/router';
import Link from 'next/link';
export default function HeaderLink(props) {
const router = useRouter();
const { columnKey, linkText } = props;
const { sort, dir } = router.query;
const classDir = dir === 'asc' ? 'asc' : 'desc';
let linkDir = 'desc';
let className = 'sort-direction';
if (sort === columnKey) {
linkDir = dir === 'desc' ? 'asc' : 'desc';
className = `${className} ${classDir}`;
}
const query = {
// Keep existing query params.
...router.query,
// Override ones related to sort.
dir: linkDir,
sort: columnKey,
};
return (
<Link href={{ pathname: router.pathname, query }} passHref>
<a className={className}>{linkText}</a>
</Link>
);
}

9
components/YesNoBool.js Normal file
Просмотреть файл

@ -0,0 +1,9 @@
export default function YesNoBool(props) {
const { bool, extraClasses } = props;
const yesOrNo = bool === true ? 'yes' : 'no';
let classNames = [yesOrNo];
if (extraClasses && extraClasses[yesOrNo]) {
classNames = [...classNames, ...extraClasses[yesOrNo]];
}
return <span className={classNames.join(' ')}>{yesOrNo.toUpperCase()}</span>;
}

Просмотреть файл

@ -16,8 +16,8 @@ http {
server_tokens off;
log_format l2met 'measure#nginx.service=$request_time request_id=$http_x_request_id';
access_log logs/nginx/access.log l2met;
error_log logs/nginx/error.log;
access_log <%= ENV['NGINX_ACCESS_LOG_PATH'] || 'logs/nginx/access.log' %> l2met;
error_log <%= ENV['NGINX_ERROR_LOG_PATH'] || 'logs/nginx/error.log' %>;
include mime.types;
default_type application/octet-stream;
@ -26,7 +26,9 @@ http {
# Must read the body in 5 seconds.
client_body_timeout 5;
upstream node_api_server {
proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off;
upstream nextjs_upstream {
server unix:/tmp/nginx.socket fail_timeout=0;
}
@ -37,45 +39,62 @@ http {
port_in_redirect off;
more_clear_headers 'X-Powered-By';
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security max-age=31536000 always;
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;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
if ($http_x_forwarded_proto != 'https') {
rewrite ^ https://$host$request_uri? permanent;
}
location / {
root build/;
try_files $uri /index.html =404;
# A default restrictive CSP that should always be overriden by location blocks.
include sec-headers-base.conf;
# All the JS / CSS served by next.
location /_next/static {
# Next.js serves far-futures expires itself.
# This caching will have nginx serve statics (after the first request)
# rather than hitting the app-server.
proxy_cache STATIC;
proxy_pass http://nextjs_upstream;
# For testing cache - remove before deploying to production
add_header X-Cache-Status $upstream_cache_status;
# Full sec headers so error pages work.
include sec-headers.conf;
}
location /api/ {
# Serves static files added to public/static
location /static {
proxy_cache STATIC;
proxy_ignore_headers Cache-Control;
proxy_cache_valid 60m;
add_header X-Cache-Status $upstream_cache_status;
# Full sec headers so error pages work.
include sec-headers.conf;
proxy_pass http://nextjs_upstream;
}
location /api {
# Full sec headers so error pages work.
include sec-headers.conf;
proxy_pass http://nextjs_upstream;
}
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security max-age=31536000 always;
add_header Content-Security-Policy "
default-src 'none';
base-uri 'none';
form-action 'none';
object-src 'none'" always;
include sec-headers.conf;
proxy_pass http://node_api_server;
proxy_pass http://nextjs_upstream;
}
}
}

Просмотреть файл

@ -0,0 +1,9 @@
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Strict-Transport-Security max-age=31536000 always;
add_header Content-Security-Policy "
default-src 'none';
base-uri 'none';
form-action 'none';
object-src 'none'" always;
add_header referrer-policy no-referrer-when-downgrade;

14
config/sec-headers.conf Normal file
Просмотреть файл

@ -0,0 +1,14 @@
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Strict-Transport-Security max-age=31536000 always;
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;
add_header referrer-policy no-referrer-when-downgrade;

27
jest.config.js Normal file
Просмотреть файл

@ -0,0 +1,27 @@
// Jest.config.js
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
collectCoverageFrom: ['**/*.{js,jsx}'],
// The directory where Jest should output its coverage files
coverageDirectory: '.coverage',
coveragePathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/fixtures/',
'<rootDir>/.next/',
'<rootDir>/.coverage/',
'<rootDir>/jest.config.js',
'<rootDir>/jest.setup.js',
'<rootDir>/next.config.js',
],
// Module lookup ordering.
moduleDirectories: ['<rootDir>', 'node_modules'],
// A list of paths to modules that run some code to configure or set up the testing
// framework before each test
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/tests/mocks/fileMock.js',
'\\.(scss|css)$': '<rootDir>/tests/mocks/styleMock.js',
},
setupFilesAfterEnv: ['./jest.setup.js'],
};

15
jest.setup.js Normal file
Просмотреть файл

@ -0,0 +1,15 @@
// Jest.setup.js
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
// Set __NEXT_TRAILING_SLASH to configure trailing slashes for tests
// Temp workaround for https://github.com/vercel/next.js/issues/16094
const { trailingSlash } = require('./next.config');
process.env = { ...process.env, __NEXT_TRAILING_SLASH: trailingSlash };
// Turn off console for tests.
jest.spyOn(global.console, 'log').mockImplementation(() => jest.fn());
jest.spyOn(global.console, 'debug').mockImplementation(() => jest.fn());
global.fetch = require('fetch-mock');

5
jsconfig.json Normal file
Просмотреть файл

@ -0,0 +1,5 @@
{
"compilerOptions": {
"baseUrl": "."
}
}

125
lib/bzapi.js Normal file
Просмотреть файл

@ -0,0 +1,125 @@
import queryString from 'query-string';
import serverSWR from './serverSWR';
export const baseWEBURL = 'https://bugzilla.mozilla.org/buglist.cgi';
const baseAPIURL = 'https://bugzilla.mozilla.org/rest/bug';
const needsInfoParams = {
include_fields: 'id',
f1: 'flagtypes.name',
f2: 'requestees.login_name',
o1: 'casesubstring',
o2: 'equals',
v1: 'needinfo?',
v2: null, // Placeholder for the user email.
};
const whiteboardTagParams = {
status_whiteboard_type: 'allwordssubstr',
status_whiteboard: null, // Placeholder for the whiteboard tag to look for.
};
const webExtOnlyParams = {
component: [
'Add-ons Manager',
'Android',
'Compatibility',
'Developer Outreach',
'Developer Tools',
'Experiments',
'Frontend',
'General',
'Request Handling',
'Storage',
'Themes',
'Untriaged',
],
product: ['Toolkit', 'WebExtensions'],
};
const openBugParams = {
resolution: '---',
bug_status: ['ASSIGNED', 'NEW', 'REOPENED', 'UNCONFIRMED'],
};
export function fetchIssueCount({ priority, product, bug_severity } = {}) {
const params = {
product,
priority,
bug_severity,
count_only: true,
limit: 0,
};
/* istanbul ignore next */
if (params.bug_priority && params.bug_severity) {
throw new Error('Query only severity or priority independently');
}
if (bug_severity) {
delete params.priority;
}
if (priority) {
delete params.bug_severity;
}
if (product === 'Toolkit') {
params.component = 'Add-ons Manager';
} else if (product === 'Firefox') {
params.component = 'Extension Compatibility';
}
const apiURL = `${baseAPIURL}?${queryString.stringify({
...params,
...openBugParams,
})}`;
const webParams = { ...params, ...openBugParams };
delete webParams.count_only;
const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`;
return serverSWR(apiURL, async () => {
const res = await fetch(apiURL, {
headers: { 'Content-Type': 'application/json' },
});
const json = await res.json();
return { count: json.bug_count, url: webURL };
});
}
export function fetchNeedInfo(email) {
const apiParams = { ...needsInfoParams, ...webExtOnlyParams };
apiParams.v2 = email;
const apiURL = `${baseAPIURL}?${queryString.stringify(apiParams)}`;
return serverSWR(apiURL, async () => {
const result = await fetch(apiURL, {
headers: { 'Content-Type': 'application/json' },
});
return result.json();
});
}
export function fetchWhiteboardTag(whiteboardTag) {
const apiParams = {
...whiteboardTagParams,
...webExtOnlyParams,
...openBugParams,
status_whiteboard: whiteboardTag,
count_only: true,
};
const webParams = { ...apiParams };
delete webParams.count_only;
const apiURL = `${baseAPIURL}?${queryString.stringify(apiParams)}`;
const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`;
return serverSWR(apiURL, async () => {
const result = await fetch(apiURL, {
headers: { 'Content-Type': 'application/json' },
});
const json = await result.json();
return { count: json.bug_count, url: webURL };
});
}

Просмотреть файл

@ -1,16 +1,16 @@
const validYears = ['2017', '2018', '2019', '2020', '2021'];
module.exports = {
validYears: validYears,
validYears,
validYearRX: new RegExp(`^(?:${validYears.join('|')})$`),
validMilestoneRX: new RegExp(
`^(?:${validYears.join(
`^(?<year>${validYears.join(
'|',
)})\\.(?:0[1-9]|1[0-2])\\.(?:0[1-9]|[1-2]\\d|3[0-1])$`,
)})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[1-2]\\d|3[0-1])$`,
),
validQuarterRX: /^Q[1-4]$/,
// This defined what team members projects can be filtered by since projects don't have an official
// assignment.
// This defined what team members projects can be filtered by since projects don't have
// an official assignment.
// It should contain anyone who owned an add-ons project past and present.
// Note: Removing people no longer in the team will prevent old projects related to them
// being accessible by URL.
@ -26,10 +26,6 @@ module.exports = {
'willdurand',
'xlisachan',
],
API_ROOT:
process.env.NODE_ENV === 'production'
? '/api'
: 'http://localhost:3000/api',
colors: {
blocked: '#ffa500',
closed: '#98ff98',
@ -53,4 +49,30 @@ module.exports = {
'state: wontfix',
],
priorities: ['p1', 'p2', 'p3', 'p4', 'p5'],
contribRepos: [
'mozilla/addons',
'mozilla/addons-code-manager',
'mozilla/addons-server',
'mozilla/addons-frontend',
'mozilla/addons-linter',
'mozilla/dispensary',
'mozilla/extension-workshop',
'mozilla/sign-addon',
'mozilla/web-ext',
'mozilla/webextension-polyfill',
'mozilla/FirefoxColor',
],
bugzilla: {
priorities: ['--', 'P1', 'P2', 'P3', 'P4', 'P5'],
severities: ['normal', '--', 'N/A', 'S1', 'S2', 'S3', 'S4'],
products: ['Toolkit', 'WebExtensions', 'Firefox'],
whiteboardTags: [
'addons-ux',
'stockwell disable-recommended',
'stockwell fixed',
'stockwell disabled',
'stockwell needswork:owner',
'stockwell infra',
],
},
};

Просмотреть файл

56
lib/ghapi.js Normal file
Просмотреть файл

@ -0,0 +1,56 @@
import { ApolloClient } from 'apollo-client';
import { createHttpLink } from 'apollo-link-http';
import {
InMemoryCache,
IntrospectionFragmentMatcher,
} from 'apollo-cache-inmemory';
import hash from 'object-hash';
import introspectionQueryResultData from './fragmentTypes.json';
import serverSWR from './serverSWR';
export default function createClient() {
const headers = {};
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'test') {
if (process.env.GH_TOKEN) {
headers.Authorization = `token ${process.env.GH_TOKEN}`;
} else {
throw new Error('No GH_TOKEN found');
}
}
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
// For fetches to work correctly we use a new client instance for
// each request to avoid stale data.
const gqlClient = new ApolloClient({
link: createHttpLink({
uri: 'https://api.github.com/graphql',
headers,
}),
cache: new InMemoryCache({
fragmentMatcher,
}),
});
// Client with serverSWR wrapper to carry out in memory caching of the original API response
// from githubs GraphQL API.
const client = {
query: async ({ query, variables }) => {
// Create a hash based on the query with variables.
const keyHash = hash(
{ queryAsString: query.loc.source.body.toString(), variables },
{ algorithm: 'sha256' },
);
return serverSWR(keyHash, async () => {
const result = await gqlClient.query({ query, variables });
return result;
});
},
};
return client;
}

Просмотреть файл

@ -1,6 +1,7 @@
/*
* This file provides a fetch that uses swr's cache under the covers.
* This enables caching to be handled in a specific way for API calls we're carrying out from the server.
* This enables caching to be handled in a specific way for API calls we're carrying
* out from the server.
*
*/
@ -48,9 +49,8 @@ const serverSWR = async (
fetchAndCache();
}
return cachedData.response;
} else {
return fetchAndCache();
}
return fetchAndCache();
};
module.exports = serverSWR;

31
lib/utils/contrib.js Normal file
Просмотреть файл

@ -0,0 +1,31 @@
import { hasLabelContainingString } from 'lib/utils';
import { priorities } from 'lib/const';
export function formatContribData(data) {
const issues = [];
data.forEach((item) => {
const issue = {
...item.issue,
priority: '',
assigned: false,
mentorAssigned: false,
};
const labels = issue.labels.nodes || [];
priorities.forEach((priority) => {
if (hasLabelContainingString(labels, priority)) {
issue.priority = priority;
}
});
if (hasLabelContainingString(labels, 'contrib: assigned')) {
issue.assigned = true;
}
if (hasLabelContainingString(labels, 'contrib: mentor assigned')) {
issue.mentorAssigned = true;
}
if (issue.repository && issue.repository.name) {
issue.repo = issue.repository.name;
}
issues.push(issue);
});
return issues;
}

67
lib/utils/index.js Normal file
Просмотреть файл

@ -0,0 +1,67 @@
import DOMPurifyLib from 'dompurify';
import queryString from 'query-string';
// Create an DOMPurify instance in a universal way.
let DOMPurify;
if (typeof window === 'undefined') {
// eslint-disable-next-line global-require
const { JSDOM } = require('jsdom');
const { window } = new JSDOM('<!DOCTYPE html>');
DOMPurify = DOMPurifyLib(window);
} else {
DOMPurify = DOMPurifyLib;
}
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if ('target' in node) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
export const { sanitize } = DOMPurify;
export function hasLabel(issueLabels, labelOrLabelList) {
const labels = issueLabels || [];
if (Array.isArray(labelOrLabelList)) {
return labels.some((item) => labelOrLabelList.includes(item.name));
}
return !!labels.find((label) => label.name === labelOrLabelList);
}
export function hasLabelContainingString(issueLabels, string) {
const labels = issueLabels || [];
const rx = new RegExp(string);
return !!labels.find((label) => rx.test(label.name));
}
export function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: {};
}
export function colourIsLight(hex) {
const { r, g, b } = hexToRgb(hex);
// Counting the perceptive luminance
// human eye favors green color...
const a = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return a < 0.5;
}
export function getApiURL(path, queryParams) {
if (!path.startsWith('/api')) {
throw new Error(`Path should start with '/api'`);
}
const host = process.env.API_HOST || '';
let apiUrl = `${host}${path}`;
if (queryParams) {
apiUrl = `${apiUrl}?${queryString.stringify(queryParams)}`;
}
return apiUrl;
}

215
lib/utils/milestones.js Normal file
Просмотреть файл

@ -0,0 +1,215 @@
import { oneLineTrim } from 'common-tags';
import { colors, priorities } from 'lib/const';
import { colourIsLight, hasLabel, hasLabelContainingString } from 'lib/utils';
/*
* This function should return the next nearest release
* date including if the release date is today.
* dayOfWeek: Sunday is 0, Monday is 1 etc...
*/
export function getNextMilestone({
dayOfWeek = 4,
startDate = new Date(),
} = {}) {
if (startDate.getDay() === dayOfWeek) {
return startDate;
}
const resultDate = new Date(startDate.getTime());
resultDate.setDate(
startDate.getDate() + ((7 + dayOfWeek - startDate.getDay() - 1) % 7) + 1,
);
return resultDate;
}
/*
* Formats a date object into a milestone format YYYY.MM.DD
* Handles zero filling so 2019.1.1 will be 2019.01.01
*/
export function formatDateToMilestone(date) {
return oneLineTrim`${date.getFullYear()}-
${(date.getMonth() + 1).toString().padStart(2, '0')}-
${date.getDate().toString().padStart(2, '0')}`;
}
/*
* Computes an object with pagination data based on starting day of week and defaulting
* to the current date.
*
*/
export function getMilestonePagination({
dayOfWeek = 4,
startDate = new Date(),
} = {}) {
// The nearest release milestone to the starting point.
let nextMilestone = getNextMilestone({ dayOfWeek, startDate });
const prev = new Date(
nextMilestone.getFullYear(),
nextMilestone.getMonth(),
nextMilestone.getDate() - 7,
);
// Set next Milestone to 7 days time if we're starting on current milestone date already.
if (
formatDateToMilestone(startDate) === formatDateToMilestone(nextMilestone)
) {
nextMilestone = new Date(
nextMilestone.getFullYear(),
nextMilestone.getMonth(),
nextMilestone.getDate() + 7,
);
}
// The current milestone closest to today.
const currentMilestone = getNextMilestone(dayOfWeek);
return {
// The milestone before the startDate.
prevFromStart: formatDateToMilestone(prev),
// The startDate milestone (might not be a typical release day).
start: formatDateToMilestone(startDate),
// The milestone after the startDate.
nextFromStart: formatDateToMilestone(nextMilestone),
// The current closest milestone to today.
current: formatDateToMilestone(currentMilestone),
};
}
// Set priority if there's a priority label associated with the issue.
export function setIssuePriorityProp(issue) {
const labels = (issue.labels && issue.labels.nodes) || [];
issue.priority = null;
priorities.forEach((priority) => {
if (hasLabelContainingString(labels, priority)) {
issue.priority = priority;
}
});
}
// Set the repo name directly on the issue.
export function setRepoProp(issue) {
if (issue.repository && issue.repository.name) {
issue.repo = issue.repository.name;
}
}
// Update project info,
export function setProjectProps(issue) {
issue.hasProject = false;
if (
issue.projectCards &&
issue.projectCards.nodes &&
issue.projectCards.nodes.length
) {
issue.hasProject = true;
issue.projectUrl = issue.projectCards.nodes[0].project.url;
issue.projectName = issue.projectCards.nodes[0].project.name;
}
}
// Add assignee prop pointing to the login of the first assignee.
export function setAssigneeProp(issue) {
const labels = (issue.labels && issue.labels.nodes) || [];
issue.isContrib = false;
issue.assignee = '00_unassigned';
if (issue.assignees.nodes.length) {
issue.assignee = issue.assignees.nodes[0].login;
} else if (hasLabelContainingString(labels, 'contrib: assigned')) {
issue.isContrib = true;
issue.assignee = '01_contributor';
}
}
export function setReviewerDetails(issue) {
issue.reviewers = [];
const reviewersListSeen = [];
if (issue.state === 'CLOSED') {
issue.timelineItems.edges.forEach((timelineItem) => {
if (!timelineItem.event.source.reviews) {
// This is not a pull request item.
return;
}
const { bodyText } = timelineItem.event.source;
const issueTestRx = new RegExp(`Fix(?:es)? #${issue.number}`, 'i');
// Only add the review if the PR contains a `Fixes #num` or `Fix #num` line that
// matches the original issue.
if (issueTestRx.test(bodyText)) {
timelineItem.event.source.reviews.edges.forEach(
({ review: { author } }) => {
if (!reviewersListSeen.includes(author.login)) {
reviewersListSeen.push(author.login);
issue.reviewers.push({
author,
prLink: timelineItem.event.source.permalink,
});
}
},
);
}
});
}
// Quick and dirty way to provide a sortable key for reviewers.
issue.reviewersNames = '';
if (issue.reviewers.length) {
issue.reviewersNames = issue.reviewers
.map((review) => review.author.login)
.join('-');
}
}
export function setStateLabels(issue) {
const labels = (issue.labels && issue.labels.nodes) || [];
// Define current state of the issue.
issue.stateLabel = issue.state.toLowerCase();
issue.stateLabelColor =
issue.state === 'CLOSED' ? colors.closed : colors.open;
if (issue.state === 'OPEN' && hasLabel(labels, 'state: pull request ready')) {
issue.stateLabel = 'PR ready';
issue.stateLabelColor = colors.prReady;
} else if (issue.state === 'OPEN' && hasLabel(labels, 'state: in progress')) {
issue.stateLabel = 'in progress';
issue.stateLabelColor = colors.inProgress;
} else if (
issue.state === 'CLOSED' &&
hasLabel(labels, 'state: verified fixed')
) {
issue.stateLabel = 'verified fixed';
issue.stateLabelColor = colors.verified;
} else if (issue.state === 'CLOSED' && hasLabel(labels, 'qa: not needed')) {
issue.stateLabel = 'closed QA-';
issue.stateLabelColor = colors.verified;
}
issue.stateLabelTextColor = colourIsLight(issue.stateLabelColor)
? '#000'
: '#fff';
}
/*
* This function massages the issue data and adds additional properties
* to make it easier to display.
*/
export function formatIssueData(jsonData) {
const issues = [];
if (jsonData.data && jsonData.data.milestone_issues) {
const issueData = jsonData.data.milestone_issues.results;
issueData.forEach((item) => {
// Set defaults.
const { issue } = item;
setIssuePriorityProp(issue);
setRepoProp(issue);
setProjectProps(issue);
setStateLabels(issue);
setAssigneeProp(issue);
setReviewerDetails(issue);
issues.push(issue);
});
}
return issues;
}

122
lib/utils/projects.js Normal file
Просмотреть файл

@ -0,0 +1,122 @@
/*
* Defaulting to starting with today's date work out the current year and quarter.
*
*/
export function getCurrentQuarter({ _date } = {}) {
const today = _date || new Date();
const year = today.getFullYear();
const quarter = `Q${Math.floor((today.getMonth() + 3) / 3)}`;
return {
year,
quarter,
};
}
/*
* Get the previous quarter and year, from starting with a quarter and year as input.
*
*/
export function getPrevQuarter({ quarter, year } = {}) {
if (!quarter || !year) {
return {};
}
const numericQuarter = quarter.substr(1);
let newQuarter = parseInt(numericQuarter, 10);
let newYear = parseInt(year, 10);
if (newQuarter > 1) {
newQuarter = newQuarter - 1;
} else if (newQuarter === 1) {
newQuarter = 4;
newYear = newYear - 1;
}
return {
year: newYear,
quarter: `Q${newQuarter}`,
};
}
/*
* Get the next quarter and year, starting with a quarter and year as input.
*
*/
export function getNextQuarter({ quarter, year } = {}) {
if (!quarter || !year) {
return {};
}
const numericQuarter = quarter.substr(1);
let newYear = parseInt(year, 10);
let newQuarter = parseInt(numericQuarter, 10);
if (newQuarter < 4) {
newQuarter = newQuarter + 1;
} else if (newQuarter === 4) {
newQuarter = 1;
newYear = newYear + 1;
}
return {
year: newYear,
quarter: `Q${newQuarter}`,
};
}
/*
* This is a universal wrapper for DOMParser
*/
export function getDOMParser() {
if (typeof window === 'undefined' && require) {
// eslint-disable-next-line global-require
const { JSDOM } = require('jsdom');
const { DOMParser } = new JSDOM().window;
return DOMParser;
}
// eslint-disable-next-line no-undef
return window.DOMParser;
}
/*
* This function parses the specially formatted HTML we add to projects to
* provide additional metadata about the projects.
* This is mostly to workaround the lack of features like labels on gh projects.
*/
export function parseProjectMeta(HTML) {
const DParser = getDOMParser();
const parser = new DParser();
const doc = parser.parseFromString(HTML, 'text/html');
const engineers = doc
.evaluate(
"//details//dl/dt[contains(., 'Engineering')]/following-sibling::dd[1]",
doc,
null,
2,
null,
)
.stringValue.replace(/ ?@/g, '')
.split(',');
const goalType = doc
.evaluate(
"//details//dl/dt[contains(., 'Goal Type')]/following-sibling::dd[1]",
doc,
null,
2,
null,
)
.stringValue.toLowerCase();
const size = doc.evaluate(
"//details//dl/dt[contains(., 'Size')]/following-sibling::dd[1]",
doc,
null,
2,
null,
).stringValue;
const details = doc.querySelector('details');
if (details) {
// Remove the meta data HTML from the doc.
details.parentNode.removeChild(details);
}
return [{ engineers, goalType, size }, doc.documentElement.outerHTML];
}

55
lib/utils/sort.js Normal file
Просмотреть файл

@ -0,0 +1,55 @@
/* eslint-disable no-console */
export function dateSort(key) {
return (a, b) => {
return new Date(a[key]) - new Date(b[key]);
};
}
export function numericSort(key) {
return (a, b) => {
return a[key] - b[key];
};
}
export function alphaSort(key) {
return (a, b) => {
const strA = a[key].toUpperCase();
const strB = b[key].toUpperCase();
if (strA < strB) {
return -1;
}
if (strA > strB) {
return 1;
}
// names must be equal
return 0;
};
}
export function sortData({ columnKey, data, direction, sortConfig } = {}) {
if (!data) {
console.debug('No data yet, bailing');
return data;
}
if (!Object.keys(sortConfig).includes(columnKey)) {
console.debug(
`"${columnKey}" does not match one of "${Object.keys(sortConfig).join(
', ',
)}"`,
);
return data;
}
if (!['desc', 'asc'].includes(direction)) {
console.debug(`"${direction}" does not match one of 'asc' or 'desc'`);
return data;
}
const sortFunc = sortConfig[columnKey].sortFunc || alphaSort;
const sorted = [].concat(data).sort(sortFunc(columnKey));
// Reverse for desc.
if (direction === 'desc') {
sorted.reverse();
}
return sorted;
}

3
next.config.js Normal file
Просмотреть файл

@ -0,0 +1,3 @@
module.exports = {
trailingSlash: true,
};

Просмотреть файл

@ -1,74 +1,57 @@
{
"name": "addons-pm",
"version": "0.1.0",
"version": "2.0.0",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/mozilla/addons-pm.git"
},
"author": "Mozilla Add-ons Team",
"license": "MPL-2.0",
"dependencies": {
"@githubprimer/octicons-react": "8.5.0",
"apollo-boost": "0.4.9",
"bootstrap": "4.6.0",
"classnames": "2.2.6",
"common-tags": "1.8.0",
"dompurify": "2.2.6",
"dotenv": "8.2.0",
"graphql": "15.5.0",
"graphql-tag": "2.11.0",
"isomorphic-fetch": "3.0.0",
"query-string": "6.13.8",
"react": "16.14.0",
"react-bootstrap": "1.4.3",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-router-bootstrap": "0.25.0",
"react-router-dom": "5.2.0",
"react-scripts": "3.4.4",
"react-timeago": "5.2.0",
"swr": "0.4.1"
},
"scripts": {
"start-server": "node src/server/index.js",
"build": "react-scripts build",
"test": "react-scripts test --env=jest-environment-jsdom-sixteen",
"test-coverage": "npm run test -- --watchAll=false --coverage --collectCoverageFrom=src/client/**/*js --collectCoverageFrom=src/server/**/*js",
"test-ci": "npm run test-coverage && codecov",
"eject": "react-scripts eject",
"lint": "eslint src",
"heroku-postbuild": "INLINE_RUNTIME_CHUNK=false yarn build",
"prettier-full": "prettier --write '**'",
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest . --watch",
"test-ci": "jest . --coverage --maxWorkers=2",
"lint": "eslint .",
"prettier": "prettier --write '**'",
"prettier-ci": "prettier -c '**'",
"prettier": "pretty-quick --branch master",
"stmux": "stmux -nM -t addons-pm -e 'error,!0 errors,!no errors'",
"stylelint": "stylelint --syntax scss **/*.scss",
"start": "yarn stmux [ 'yarn test' .. [ 'BROWSER=none yarn react-scripts start' : 'yarn start-server' ]]"
"stylelint": "stylelint --syntax scss **/*.scss"
},
"dependencies": {
"@primer/octicons-react": "11.1.0",
"apollo-boost": "^0.4.9",
"bootstrap": "4.5.3",
"classnames": "^2.2.6",
"common-tags": "^1.8.0",
"dompurify": "^2.2.3",
"graphql": "^15.4.0",
"graphql-tag": "^2.11.0",
"isomorphic-fetch": "^3.0.0",
"jsdom": "^16.4.0",
"next": "10.0.6",
"nprogress": "^0.2.0",
"object-hash": "^2.0.3",
"query-string": "^6.13.7",
"react": "17.0.1",
"react-bootstrap": "1.4.0",
"react-dom": "17.0.1",
"react-helmet": "^6.1.0",
"react-timeago": "^5.2.0",
"sass": "^1.29.0",
"swr": "^0.3.9"
},
"devDependencies": {
"@testing-library/react": "11.2.5",
"codecov": "3.8.1",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"fetch-mock": "9.11.0",
"jest-environment-jsdom-sixteen": "1.0.3",
"jest-enzyme": "7.1.2",
"mock-express-request": "0.2.2",
"mock-express-response": "0.3.0",
"node-sass": "4.14.1",
"prettier": "2.2.1",
"pretty-quick": "3.1.0",
"sinon": "9.2.4",
"stmux": "1.8.1",
"stylelint": "13.9.0",
"stylelint-config-standard": "20.0.0",
"stylelint-config-suitcss": "15.0.0"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
"@babel/core": "^7.12.9",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.6.3",
"eslint": "^7.15.0",
"eslint-config-amo": "^3.9.0",
"eslint-plugin-amo": "^1.13.0",
"fetch-mock": "^9.11.0",
"jest": "^26.6.3",
"mock-express-request": "^0.2.2",
"mock-express-response": "^0.2.2",
"prettier": "^2.2.1",
"stylelint": "^13.9.0",
"stylelint-config-standard": "^20.0.0",
"stylelint-config-suitcss": "^15.0.0"
}
}

97
pages/_app.js Normal file
Просмотреть файл

@ -0,0 +1,97 @@
import '../styles/globals.scss';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'nprogress/nprogress.css';
import { Nav, Navbar } from 'react-bootstrap';
import { MarkGithubIcon } from '@primer/octicons-react';
import React from 'react';
import { Helmet } from 'react-helmet';
import NProgress from 'nprogress';
import { useRouter } from 'next/router';
import Link from 'next/link';
function MyApp({ Component, pageProps }) {
const router = useRouter();
React.useEffect(() => {
const routeChangeStart = () => {
NProgress.start();
};
const routeChangeComplete = () => {
NProgress.done();
};
router.events.on('routeChangeStart', routeChangeStart);
router.events.on('routeChangeComplete', routeChangeComplete);
router.events.on('routeChangeError', routeChangeComplete);
return () => {
router.events.off('routeChangeStart', routeChangeStart);
router.events.off('routeChangeComplete', routeChangeComplete);
router.events.off('routeChangeError', routeChangeComplete);
};
});
return (
<div data-testid="app-wrapper">
<Helmet defaultTitle="Add-ons PM" titleTemplate="%s - Add-ons PM" />
<Navbar bg="dark" variant="dark">
<Nav className="mr-auto">
<Nav.Item>
<Link href="/" passHref>
<Nav.Link className="navbar-brand" eventKey={0}>
Addons PM
</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href="/projects/latest/" passHref>
<Nav.Link eventKey={1}>Projects</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href="/milestones/latest/?dir=asc&sort=assignee" passHref>
<Nav.Link eventKey={2}>Milestones</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href="/dashboards/amo/" passHref>
<Nav.Link eventKey={3}>AMO Dashboard</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href="/dashboards/webext/" passHref>
<Nav.Link eventKey={4}>Webext Dashboard</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link
href="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
passHref
>
<Nav.Link eventKey={5}>Contributions</Nav.Link>
</Link>
</Nav.Item>
</Nav>
<Nav className="mr-sm-2">
<Nav.Item>
<Nav.Link
data-ref="src"
eventKey="src"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/mozilla/addons-pm/"
>
<MarkGithubIcon
verticalAlign="middle"
aria-label="View on Github"
size="medium"
/>
</Nav.Link>
</Nav.Item>
</Nav>
</Navbar>
<Component {...pageProps} />
</div>
);
}
export default MyApp;

40
pages/_document.js Normal file
Просмотреть файл

@ -0,0 +1,40 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { Helmet } from 'react-helmet';
export default class MyDocument extends Document {
static async getInitialProps(...args) {
const documentProps = await super.getInitialProps(...args);
// see https://github.com/nfl/react-helmet#server-usage for more information
// 'head' was occupied by 'renderPage().head', we cannot use it
return { ...documentProps, helmet: Helmet.renderStatic() };
}
// should render on <html>
get helmetHtmlAttrComponents() {
return this.props.helmet.htmlAttributes.toComponent();
}
// should render on <body>
get helmetBodyAttrComponents() {
return this.props.helmet.bodyAttributes.toComponent();
}
// should render on <head>
get helmetHeadComponents() {
return Object.keys(this.props.helmet)
.filter((el) => el !== 'htmlAttributes' && el !== 'bodyAttributes')
.map((el) => this.props.helmet[el].toComponent());
}
render() {
return (
<Html {...this.helmetHtmlAttrComponents}>
<Head>{this.helmetHeadComponents}</Head>
<body {...this.helmetBodyAttrComponents}>
<Main />
<NextScript />
</body>
</Html>
);
}
}

Просмотреть файл

@ -0,0 +1,68 @@
import { fetchIssueCount } from 'lib/bzapi';
import { bugzilla } from 'lib/const';
export default async (req, res) => {
const requests = [];
const combinedData = {};
for (const product of bugzilla.products) {
combinedData[product] = {};
for (const priority of bugzilla.priorities) {
requests.push(
fetchIssueCount({
product,
priority,
bug_severity: null,
}).then((result) => {
let priorityLabel;
switch (priority) {
case '--':
priorityLabel = 'default';
break;
default:
priorityLabel = priority.toLowerCase();
}
combinedData[product][`priority-${priorityLabel}`] = result;
}),
);
}
for (const bug_severity of bugzilla.severities) {
requests.push(
fetchIssueCount({
product,
bug_severity,
priority: null,
}).then((result) => {
let severityLabel;
switch (bug_severity) {
case 'N/A':
severityLabel = 'not-applicable';
break;
case '--':
severityLabel = 'default';
break;
default:
severityLabel = bug_severity.toLowerCase();
}
combinedData[product][`severity-${severityLabel}`] = result;
}),
);
}
requests.push(
fetchIssueCount({
product,
bug_severity: null,
priority: null,
}).then((result) => {
combinedData[product].total = result;
}),
);
}
return Promise.all(requests).then(() => {
res.json(combinedData);
});
};

Просмотреть файл

@ -0,0 +1,27 @@
import { fetchNeedInfo, baseWEBURL } from 'lib/bzapi';
export default async (req, res) => {
const requests = [];
const combinedData = {};
const BZ_USERS = JSON.parse(process.env.BZ_USERS) || {};
for (const nick in BZ_USERS) {
if (Object.prototype.hasOwnProperty.call(BZ_USERS, nick)) {
combinedData[nick] = {};
requests.push(
fetchNeedInfo(BZ_USERS[nick]).then((result) => {
combinedData[nick].count = result.bugs.length;
if (result.bugs.length) {
combinedData[nick].url = `${baseWEBURL}?bug_id=${result.bugs
.map((item) => item.id)
.join(',')}`;
}
}),
);
}
}
return Promise.all(requests).then(() => {
res.json(combinedData);
});
};

Просмотреть файл

@ -0,0 +1,20 @@
import { fetchWhiteboardTag } from 'lib/bzapi';
import { bugzilla } from 'lib/const';
export default async (req, res) => {
const requests = [];
const combinedData = {};
for (const whiteboardTag of bugzilla.whiteboardTags) {
combinedData[whiteboardTag] = {};
requests.push(
fetchWhiteboardTag(whiteboardTag).then((result) => {
combinedData[whiteboardTag] = result;
}),
);
}
return Promise.all(requests).then(() => {
res.json(combinedData);
});
};

Просмотреть файл

@ -1,12 +1,12 @@
const gql = require('graphql-tag').default;
const { CONTRIB_REPOS } = require('./utils');
import gql from 'graphql-tag';
import createClient from 'lib/ghapi';
import { contribRepos } from 'lib/const';
const contribWelcome = gql`
{
const query = gql`{
contrib_welcome: search(
type: ISSUE
query: """
${CONTRIB_REPOS.map((n) => `repo:${n}`).join('\n')}
${contribRepos.map((n) => `repo:${n}`).join('\n')}
label:"contrib: welcome"
is:open
sort:updated-desc
@ -37,6 +37,10 @@ const contribWelcome = gql`
}
`;
module.exports = {
contribWelcome,
export default async (req, res) => {
const client = createClient();
const data = await client.query({
query,
});
res.json(data);
};

Просмотреть файл

@ -1,12 +1,12 @@
const gql = require('graphql-tag').default;
const { CONTRIB_REPOS } = require('./utils');
import gql from 'graphql-tag';
import createClient from 'lib/ghapi';
import { contribRepos } from 'lib/const';
const goodFirstBugs = gql`
{
const query = gql`{
good_first_bugs: search(
type: ISSUE
query: """
${CONTRIB_REPOS.map((n) => `repo:${n}`).join('\n')}
${contribRepos.map((n) => `repo:${n}`).join('\n')}
label:"contrib: good first bug"
is:open
sort:updated-desc
@ -37,6 +37,10 @@ const goodFirstBugs = gql`
}
`;
module.exports = {
goodFirstBugs,
export default async (req, res) => {
const client = createClient();
const data = await client.query({
query,
});
res.json(data);
};

Просмотреть файл

@ -1,6 +1,7 @@
const gql = require('graphql-tag').default;
import gql from 'graphql-tag';
import createClient from 'lib/ghapi';
const issueCounts = gql`
const query = gql`
fragment issueCounts on Repository {
description
total_issues: issues(states: OPEN) {
@ -24,6 +25,9 @@ const issueCounts = gql`
open_p2s: issues(states: OPEN, labels: "priority: p2") {
totalCount
}
open_p3s: issues(states: OPEN, labels: "priority: p3") {
totalCount
}
open_prs: pullRequests(states: OPEN) {
totalCount
}
@ -51,6 +55,10 @@ const issueCounts = gql`
}
`;
module.exports = {
issueCounts,
export default async (req, res) => {
const client = createClient();
const data = await client.query({
query,
});
res.json(data);
};

Просмотреть файл

@ -1,12 +1,12 @@
const gql = require('graphql-tag').default;
const { CONTRIB_REPOS } = require('./utils');
import gql from 'graphql-tag';
import createClient from 'lib/ghapi';
import { contribRepos } from 'lib/const';
const maybeGoodFirstBugs = gql`
{
const query = gql`{
maybe_good_first_bugs: search(
type: ISSUE
query: """
${CONTRIB_REPOS.map((n) => `repo:${n}`).join('\n')}
${contribRepos.map((n) => `repo:${n}`).join('\n')}
label:"contrib: maybe good first bug"
is:open
sort:updated-desc
@ -37,6 +37,10 @@ const maybeGoodFirstBugs = gql`
}
`;
module.exports = {
maybeGoodFirstBugs,
export default async (req, res) => {
const client = createClient();
const data = await client.query({
query,
});
res.json(data);
};

Просмотреть файл

@ -1,6 +1,8 @@
const gql = require('graphql-tag').default;
import gql from 'graphql-tag';
import createClient from 'lib/ghapi';
import { validMilestoneRX } from 'lib/const';
const milestoneIssues = gql`
const query = gql`
query getMilestoneIssue($query: String!) {
milestone_issues: search(type: ISSUE, query: $query, first: 100) {
issueCount
@ -68,6 +70,30 @@ const milestoneIssues = gql`
}
`;
module.exports = {
milestoneIssues,
export default async (req, res) => {
const client = createClient();
let { milestone } = req.query;
// Next.js requires us to use `-` in urls instead of `.` due to
// https://github.com/vercel/next.js/issues/16617
if (!validMilestoneRX.test(milestone)) {
res.status(400).json({ error: 'Incorrect milestone format' });
} else {
milestone = milestone.replace(/-/g, '.');
const variables = {
query: `repo:mozilla/addons
repo:mozilla/addons-server
repo:mozilla/addons-frontend
repo:mozilla/addons-linter
repo:mozilla/addons-code-manager
milestone:${milestone}
type:issues`,
};
const data = await client.query({
query,
variables,
});
res.json(data);
}
};

49
pages/api/gh-projects.js Normal file
Просмотреть файл

@ -0,0 +1,49 @@
import gql from 'graphql-tag';
import createClient from 'lib/ghapi';
import { validYearRX, validQuarterRX } from 'lib/const';
const query = gql`
query getProjects($projectSearch: String!) {
organization(login: "mozilla") {
projects(first: 100, search: $projectSearch) {
nodes {
name
bodyHTML
state
url
updatedAt
columns(first: 10) {
edges {
node {
id
name
cards(first: 100, archivedStates: [NOT_ARCHIVED]) {
totalCount
}
}
}
}
}
}
}
}
`;
export default async (req, res) => {
const client = createClient();
const { year, quarter } = req.query;
if (!validYearRX.test(year)) {
res.status(400).json({ error: 'Incorrect year format' });
} else if (!validQuarterRX.test(quarter)) {
res.status(400).json({ error: 'Incorrect quarter format' });
} else {
const projects = await client.query({
query,
variables: {
projectSearch: `Add-ons ${quarter} ${year}`,
},
});
res.json(projects);
}
};

Просмотреть файл

@ -1,6 +1,7 @@
const gql = require('graphql-tag').default;
import gql from 'graphql-tag';
import createClient from 'lib/ghapi';
const team = gql`
const query = gql`
query getAddonsTeam {
organization(login: "mozilla") {
team(slug: "addons-service-developers") {
@ -25,6 +26,10 @@ const team = gql`
}
`;
module.exports = {
team,
export default async (req, res) => {
const client = createClient();
const team = await client.query({
query,
});
res.json(team);
};

Просмотреть файл

@ -0,0 +1,44 @@
import useSWR from 'swr';
import Contrib from 'components/Contrib';
import Error from 'next/error';
import { formatContribData } from 'lib/utils/contrib';
import { getApiURL } from 'lib/utils';
const contribWelcomeURL = getApiURL('/api/gh-contrib-welcome/');
export async function getServerSideProps() {
const res = await fetch(contribWelcomeURL);
const errorCode = res.ok ? false : res.status;
const contribWelcomeData = await res.json();
return {
props: {
errorCode,
contribWelcomeData,
},
};
}
const ContribWelcome = (props) => {
if (props.errorCode) {
return <Error statusCode={props.errorCode} />;
}
const { contribWelcomeData: initialContribWelcomeData } = props;
const { data: contribData } = useSWR(
contribWelcomeURL,
async () => {
const result = await fetch(contribWelcomeURL);
const json = await result.json();
return json;
},
{ initialData: initialContribWelcomeData, refreshInterval: 30000 },
);
return (
<Contrib
contribData={formatContribData(contribData.data.contrib_welcome.results)}
/>
);
};
export default ContribWelcome;

Просмотреть файл

@ -0,0 +1,48 @@
import useSWR from 'swr';
import Error from 'next/error';
import Contrib from 'components/Contrib';
import { formatContribData } from 'lib/utils/contrib';
import { getApiURL } from 'lib/utils';
const goodFirstBugsURL = getApiURL('/api/gh-good-first-bugs/');
export async function getServerSideProps() {
const res = await fetch(goodFirstBugsURL);
const errorCode = res.ok ? false : res.status;
const goodFirstBugsData = await res.json();
return {
props: {
errorCode,
goodFirstBugsData,
},
};
}
const GoodFirstBugs = (props) => {
if (props.errorCode) {
return <Error statusCode={props.errorCode} />;
}
const { goodFirstBugsData: initialGoodFirstBugsData } = props;
const { data: goodFirstBugsData } = useSWR(
goodFirstBugsURL,
async () => {
const result = await fetch(goodFirstBugsURL);
const json = await result.json();
return json;
},
{ initialData: initialGoodFirstBugsData, refreshInterval: 30000 },
);
return (
<Contrib
contribData={formatContribData(
goodFirstBugsData.data.good_first_bugs.results,
)}
hasAssignments
/>
);
};
export default GoodFirstBugs;

Просмотреть файл

@ -0,0 +1,48 @@
import useSWR from 'swr';
import Error from 'next/error';
import Contrib from 'components/Contrib';
import { formatContribData } from 'lib/utils/contrib';
import { getApiURL } from 'lib/utils';
const maybeGoodFirstBugsURL = getApiURL('/api/gh-maybe-good-first-bugs/');
export async function getServerSideProps() {
const res = await fetch(maybeGoodFirstBugsURL);
const errorCode = res.ok ? false : res.status;
const maybeGoodFirstBugsData = await res.json();
return {
props: {
errorCode,
maybeGoodFirstBugsData,
},
};
}
const MaybeGoodFirstBugs = (props) => {
if (props.errorCode) {
return <Error statusCode={props.errorCode} />;
}
const { maybeGoodFirstBugsData: initialMaybeGoodFirstBugsData } = props;
const { data: maybeGoodFirstBugsData } = useSWR(
maybeGoodFirstBugsURL,
async () => {
const result = await fetch(maybeGoodFirstBugsURL);
const json = await result.json();
return json;
},
{ initialData: initialMaybeGoodFirstBugsData, refreshInterval: 30000 },
);
return (
<Contrib
contribData={formatContribData(
maybeGoodFirstBugsData.data.maybe_good_first_bugs.results,
)}
hasAssignments
/>
);
};
export default MaybeGoodFirstBugs;

76
pages/dashboards/amo.js Normal file
Просмотреть файл

@ -0,0 +1,76 @@
import { Helmet } from 'react-helmet';
import Error from 'next/error';
import { Container } from 'react-bootstrap';
import useSWR from 'swr';
import AMODashCountGroup from 'components/AMODashCountGroup';
import { getApiURL } from 'lib/utils';
function renderCounts(issueCountData) {
const countGroups = [];
Object.keys(issueCountData).forEach((repo) => {
countGroups.push(
<AMODashCountGroup
key={repo}
issueCounts={issueCountData[repo]}
description={issueCountData[repo].description}
repo={repo}
/>,
);
});
return countGroups;
}
const githubIssueCountsURL = getApiURL('/api/gh-issue-counts/');
export async function getServerSideProps() {
const res = await fetch(githubIssueCountsURL);
const errorCode = res.ok ? false : res.status;
const amoDashData = await res.json();
return {
props: {
errorCode,
issueCounts: amoDashData,
},
};
}
const DashboardAMO = (props) => {
if (props.errorCode) {
return <Error statusCode={props.errorCode} />;
}
const { data, error } = useSWR(
githubIssueCountsURL,
async () => {
const result = await fetch(githubIssueCountsURL);
return result.json();
},
{ refreshInterval: 30000, initialData: props.issueCounts },
);
const isLoading = !error && !data;
// const isError = error;
return (
<div className="dashboard">
<Helmet>
<title>AMO Dashboard</title>
<body className="dash" />
</Helmet>
<Container as="main">
<div className="dash-container">
{isLoading ? (
<div className="loading">
<p>Loading...</p>
</div>
) : (
renderCounts(data.data)
)}
</div>
</Container>
</div>
);
};
export default DashboardAMO;

Просмотреть файл

@ -1,16 +1,11 @@
/* eslint-disable react-hooks/rules-of-hooks */
import React from 'react';
import { Helmet } from 'react-helmet';
import { Container } from 'react-bootstrap';
import Error from 'next/error';
import useSWR from 'swr';
import { API_ROOT } from '../const';
import Client from './Client';
import DashCount from './components/DashCount';
import DashCountGroup from './components/DashCountGroup';
import './Dashboard.scss';
import DashCount from 'components/DashCount';
import DashBlank from 'components/DashBlank';
import DashCountGroup from 'components/DashCountGroup';
import { getApiURL } from 'lib/utils';
const meta = {
Toolkit: {
@ -27,14 +22,55 @@ const meta = {
},
};
function DashboardWE() {
const issueCountURL = getApiURL('/api/bz-issue-counts/');
const needInfoURL = getApiURL('/api/bz-need-infos/');
const whiteboardURL = getApiURL('/api/bz-whiteboard-tags/');
export async function getServerSideProps() {
const [
issueCountsResponse,
needInfosResponse,
whiteboardResponse,
] = await Promise.all([
fetch(issueCountURL),
fetch(needInfoURL),
fetch(whiteboardURL),
]);
const errorCode =
issueCountsResponse.ok && needInfosResponse.ok && whiteboardResponse.ok
? false
: 500;
const [issueCounts, needInfos, whiteboardTags] = await Promise.all([
issueCountsResponse.json(),
needInfosResponse.json(),
whiteboardResponse.json(),
]);
return {
props: {
errorCode,
issueCounts,
needInfos,
whiteboardTags,
},
};
}
function DashboardWE(props) {
if (props.errorCode) {
return <Error statusCode={props.errorCode} />;
}
function getIssueCounts() {
const { data, error } = useSWR(
`${API_ROOT}/bugzilla-issues-counts/`,
issueCountURL,
async () => {
return await Client.getBugzillaIssueCounts();
const result = await fetch(issueCountURL);
return result.json();
},
{ refreshInterval: 30000 },
{ refreshInterval: 30000, initialData: props.issueCounts },
);
return {
data,
@ -45,11 +81,28 @@ function DashboardWE() {
function getNeedInfos() {
const { data, error } = useSWR(
`${API_ROOT}/bugzilla-need-infos/`,
needInfoURL,
async () => {
return await Client.getBugzillaNeedInfos();
const result = await fetch(needInfoURL);
return result.json();
},
{ refreshInterval: 30000 },
{ refreshInterval: 90000, initialData: props.needInfos },
);
return {
data,
isLoading: !error && !data,
isError: error,
};
}
function getWhiteboardTags() {
const { data, error } = useSWR(
whiteboardURL,
async () => {
const result = await fetch(whiteboardURL);
return result.json();
},
{ refreshInterval: 90000, initialData: props.whiteboardTags },
);
return {
data,
@ -60,6 +113,7 @@ function DashboardWE() {
const needInfos = getNeedInfos();
const issueCounts = getIssueCounts();
const whiteboardTags = getWhiteboardTags();
function renderChild({ data, dataKey, component, title, warningLimit }) {
const { count, url } = data[dataKey];
@ -68,24 +122,20 @@ function DashboardWE() {
count={count}
key={component + title}
title={title}
warning={warningLimit && count >= warningLimit}
warningLimit={warningLimit}
link={url}
/>
);
}
function renderChildren(component, data) {
const nodes = [];
nodes.push(
return [
renderChild({
data,
dataKey: 'total',
title: 'total open',
warningLimit: null,
component,
}),
);
nodes.push(
renderChild({
data,
dataKey: 'severity-default',
@ -93,8 +143,6 @@ function DashboardWE() {
warningLimit: 15,
component,
}),
);
nodes.push(
renderChild({
data,
dataKey: 'severity-s1',
@ -102,8 +150,6 @@ function DashboardWE() {
warningLimit: 1,
component,
}),
);
nodes.push(
renderChild({
data,
dataKey: 'severity-s2',
@ -111,8 +157,6 @@ function DashboardWE() {
warningLimit: 10,
component,
}),
);
nodes.push(
renderChild({
data,
dataKey: 'priority-p1',
@ -120,8 +164,6 @@ function DashboardWE() {
warningLimit: 10,
component,
}),
);
nodes.push(
renderChild({
data,
dataKey: 'priority-p2',
@ -129,17 +171,13 @@ function DashboardWE() {
warningLimit: 20,
component,
}),
);
nodes.push(
renderChild({
data,
dataKey: 'priority-p3',
title: 'P3',
warningLimit: null,
component,
}),
);
return nodes;
];
}
function renderCounts() {
@ -148,7 +186,7 @@ function DashboardWE() {
}
const countGroups = [];
Object.keys(issueCounts.data).forEach((component, index) => {
if (meta.hasOwnProperty(component)) {
if (Object.prototype.hasOwnProperty.call(meta, component)) {
countGroups.push(
DashCountGroup({
key: index + meta[component].title,
@ -158,7 +196,8 @@ function DashboardWE() {
}),
);
} else {
console.log(`countGroup "${component}: added without meta`);
// eslint-disable-next-line no-console
console.debug(`countGroup "${component}: added without meta`);
}
});
return countGroups;
@ -166,7 +205,7 @@ function DashboardWE() {
function renderNeedInfos() {
const children = [];
Object.keys(needInfos.data).forEach((nick, index) => {
Object.keys(needInfos.data).forEach((nick) => {
children.push(
renderChild({
data: needInfos.data,
@ -178,6 +217,9 @@ function DashboardWE() {
);
});
children.push(<DashBlank key="ni-blank-1" />);
children.push(<DashBlank key="ni-blank-2" />);
return DashCountGroup({
className: 'needinfos',
key: 'needinfos',
@ -187,23 +229,51 @@ function DashboardWE() {
});
}
function renderWhiteBoardTags() {
const children = [];
Object.keys(whiteboardTags.data).forEach((tag) => {
children.push(
renderChild({
data: whiteboardTags.data,
dataKey: tag,
component: tag,
title: tag.replace('stockwell', 'STW'),
}),
);
});
children.push(<DashBlank key="ni-blank-1" />);
return DashCountGroup({
className: 'whiteboardtags',
key: 'whiteboardtags',
children,
title: 'Whiteboard Tags',
description: 'Whiteboard Tags to track',
});
}
let isLoading = false;
if (needInfos.isLoading || issueCounts.isLoading) {
if (
needInfos.isLoading ||
issueCounts.isLoading ||
whiteboardTags.isLoading
) {
isLoading = true;
}
return (
<div className="dashboard">
<Helmet>
<body className="dash" />
<title>Webextension Dashboard</title>
<body className="dash" />
</Helmet>
<Container as="main">
<div className="dash-container">
{isLoading ? (
<div className="loading">Loading...</div>
) : (
[renderNeedInfos(), ...renderCounts()]
[renderNeedInfos(), ...renderCounts(), renderWhiteBoardTags()]
)}
</div>
</Container>

138
pages/index.js Normal file
Просмотреть файл

@ -0,0 +1,138 @@
import { Button, Card, Col, Container, Row } from 'react-bootstrap';
import {
MeterIcon,
MilestoneIcon,
PeopleIcon,
ProjectIcon,
} from '@primer/octicons-react';
import { Helmet } from 'react-helmet';
import Link from 'next/link';
export default function Home() {
return (
<div>
<Helmet>
<title>Home Page</title>
</Helmet>
<br />
<Container as="main" className="home">
<Card>
<Card.Header>Projects</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<ProjectIcon size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
This view shows our current projects, in-progress for the
AMO team, plus you can navigate to previous and future
quarters.
</p>
<p>
Each project's data is provided by Github's API, and this
view is a way to provide an overview of the projects per
quarter for just our team.
</p>
<Link href="/projects/latest/" passHref>
<Button variant="outline-primary">View Projects</Button>
</Link>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
<Card>
<Card.Header>Milestones</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<MilestoneIcon size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
Milestones are used to provide an overview of what we're
shipping each week. You can also navigate to previous and
next milestones.
</p>
<p>
Each week we review what we're shipping with the current
milestone and preview what's being worked on for the
following week as part of our weekly Engineering stand-up.
</p>
<Link
href="/milestones/latest/?dir=asc&sort=assignee"
passHref
>
<Button variant="outline-primary">View Milestones</Button>
</Link>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
<Card>
<Card.Header>Dashboards</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<MeterIcon size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
These dashboards are used to give us an overview of what the
issue counts look like and highlights any high priority
bugs.
</p>
<Link href="/dashboards/amo/" passHref>
<Button variant="outline-primary">
View AMO Dashboard
</Button>
</Link>
<Link href="/dashboards/webext/" passHref>
<Button variant="outline-primary">
View Web-Extensions Dashboard
</Button>
</Link>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
<Card>
<Card.Header>Contributions</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<PeopleIcon size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
This view shows bugs that might be suitable for a
contributor to work on, this data is used as part of the
bi-weekly contributor bug review.
</p>
<Link
href="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
passHref
>
<Button variant="outline-primary">
View Contributions
</Button>
</Link>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
</Container>
</div>
);
}

Просмотреть файл

@ -0,0 +1,306 @@
import React from 'react';
import useSWR from 'swr';
import { Helmet } from 'react-helmet';
import Link from 'next/link';
import Error from 'next/error';
import { useRouter } from 'next/router';
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
import TimeAgo from 'react-timeago';
import queryString from 'query-string';
import {
AlertIcon,
HeartIcon,
LinkIcon,
PersonIcon,
} from '@primer/octicons-react';
import { getMilestonePagination, formatIssueData } from 'lib/utils/milestones';
import { dateSort, sortData } from 'lib/utils/sort';
import { getApiURL } from 'lib/utils';
import { validMilestoneRX } from 'lib/const';
import ActiveLink from 'components/ActiveLink';
import HeaderLink from 'components/HeaderLink';
const defaultSort = 'assignee';
const defaultSortDir = 'asc';
const sortConfig = {
assignee: {},
priority: {},
title: {},
repo: {},
updatedAt: {
sortFunc: dateSort,
},
hasProject: {},
state: {},
reviewersNames: {},
};
function getCurrentSortQueryString() {
const router = useRouter();
const { sort, dir } = router.query;
return `?${queryString.stringify({
dir: dir || defaultSortDir,
sort: sort || defaultSort,
})}`;
}
function renderAssignee(issue) {
if (issue.assignees.nodes.length) {
const issueAssignee = issue.assignees.nodes[0];
return (
<span>
<img className="avatar" src={issueAssignee.avatarUrl} alt="" />{' '}
{issueAssignee.login}
</span>
);
}
if (issue.assignee === '01_contributor') {
return (
<span className="contributor">
<HeartIcon verticalAlign="middle" /> Contributor
</span>
);
}
return (
<span className="unassigned">
<PersonIcon verticalAlign="middle" /> Unassigned
</span>
);
}
function renderReviewers(issue) {
const reviewers = [];
issue.reviewers.forEach((item) => {
reviewers.push(
<React.Fragment key={`${issue.number}-${item.author.login}`}>
<a href={item.prLink} target="_blank" rel="noopener noreferrer">
<img
className="avatar"
src={item.author.avatarUrl}
title={`Reviewed by ${item.author.login}`}
alt=""
/>
</a>
</React.Fragment>,
);
});
return reviewers;
}
function renderRows({ data }) {
const rows = [];
const colSpan = 7;
if (!data) {
return (
<tr>
<td colSpan={colSpan}>Loading...</td>
</tr>
);
}
if (data.length === 0) {
return (
<tr>
<td colSpan={colSpan}>
<p>There are no issues associated with this milestone yet.</p>
</td>
</tr>
);
}
for (let i = 0; i < data.length; i++) {
const issue = data[i] || {};
rows.push(
<tr key={`issue-${i}`}>
<td className="assignee">{renderAssignee(issue)}</td>
<td>
<span className={issue.priority || 'unprioritized'}>
{issue.priority ? issue.priority.toUpperCase() : <AlertIcon />}
</span>
</td>
<td>
<a
className="issueLink"
href={issue.url}
target="_blank"
rel="noopener noreferrer"
>
<strong>#{issue.number}:</strong> {issue.title}{' '}
<LinkIcon verticalAlign="middle" />
</a>
{issue.hasProject ? (
<a
href={issue.projectUrl}
target="_blank"
rel="noopener noreferrer"
className="projectLink"
>
{issue.projectName}
</a>
) : null}
</td>
<td>{issue.repository.name.replace('addons-', '')}</td>
<td>
<TimeAgo date={issue.updatedAt} />
</td>
<td>
<span
className="label"
style={{
backgroundColor: issue.stateLabelColor,
color: issue.stateLabelTextColor,
}}
>
{issue.stateLabel}
</span>
</td>
<td className="reviewers">{renderReviewers(issue)}</td>
</tr>,
);
}
return rows;
}
export async function getServerSideProps(props) {
const { milestone } = props.params;
const milestoneIssuesURL = getApiURL('/api/gh-milestone-issues/', {
milestone,
});
const res = await fetch(milestoneIssuesURL);
const errorCode = res.ok ? false : res.status;
const milestoneIssueData = await res.json();
return {
props: {
errorCode,
milestoneIssues: formatIssueData(milestoneIssueData),
},
};
}
const Milestones = (props) => {
if (props.errorCode) {
return <Error statusCode={props.errorCode} />;
}
const router = useRouter();
const { sort, dir, milestone } = router.query;
const {
groups: { year, month, day },
} = validMilestoneRX.exec(milestone);
const milestonePagination = getMilestonePagination({
startDate: new Date(year, month - 1, day),
});
const milestoneIssuesURL = getApiURL('/api/gh-milestone-issues/', {
milestone,
});
const initialMilestoneIssues = props.milestoneIssues;
const { data: milestoneIssues } = useSWR(
milestoneIssuesURL,
async () => {
const result = await fetch(milestoneIssuesURL);
const json = await result.json();
return formatIssueData(json);
},
{ initialData: initialMilestoneIssues, refreshInterval: 30000 },
);
let data = milestoneIssues;
if (sort) {
data = sortData({ data, columnKey: sort, direction: dir, sortConfig });
}
return (
<div className="Milestones">
<Helmet>
<title>Milestones</title>
</Helmet>
<Navbar
variant="muted"
bg="light"
className="shadow-sm d-flex justify-content-between"
sticky="top"
>
<Nav variant="pills">
<Nav.Item>
<Link
href={`/milestones/${
milestonePagination.prevFromStart
}/${getCurrentSortQueryString()}`}
passHref
>
<Nav.Link eventKey="prev" className="previous" active={false}>
Previous
</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link
href={`/milestones/${
milestonePagination.nextFromStart
}/${getCurrentSortQueryString()}`}
passHref
>
<Nav.Link eventKey="next" className="next" active={false}>
Next
</Nav.Link>
</Link>
</Nav.Item>
</Nav>
<Nav variant="pills">
<Nav.Item>
<ActiveLink
href={`/milestones/${
milestonePagination.current
}/${getCurrentSortQueryString()}`}
activeClassName="active"
passHref
>
<Nav.Link eventKey="current" className="current">
Current Milestone
</Nav.Link>
</ActiveLink>
</Nav.Item>
</Nav>
</Navbar>
<Container as="main" bg="light">
<h1>
Issues for milestone:
{milestone.replace(/-/g, '.')}
</h1>
<Table responsive hover>
<thead>
<tr>
<th className="assignees">
<HeaderLink columnKey="assignee" linkText="Assignee" />
</th>
<th>
<HeaderLink columnKey="priority" linkText="Priority" />
</th>
<th className="issue">
<HeaderLink columnKey="title" linkText="Issue" />
</th>
<th className="repo">
<HeaderLink columnKey="repo" linkText="Repo" />
</th>
<th className="last-updated">
<HeaderLink columnKey="updatedAt" linkText="Last Update" />
</th>
<th className="state">
<HeaderLink columnKey="state" linkText="State" />
</th>
<th>
<HeaderLink columnKey="reviewersNames" linkText="Reviewers" />
</th>
</tr>
</thead>
<tbody>{renderRows({ data })}</tbody>
</Table>
</Container>
</div>
);
};
export default Milestones;

Просмотреть файл

@ -0,0 +1,24 @@
import queryString from 'query-string';
import { getNextMilestone, getMilestonePagination } from 'lib/utils/milestones';
export default function Page() {
return null;
}
export async function getServerSideProps(props) {
let queryParams = '';
if (props.query) {
queryParams = `?${queryString.stringify(props.query)}`;
}
const milestonePagination = getMilestonePagination({
startDate: getNextMilestone(),
});
return {
redirect: {
permanent: false,
destination: `/milestones/${milestonePagination.current}/${queryParams}`,
},
};
}

Просмотреть файл

@ -0,0 +1,336 @@
import useSWR from 'swr';
import { useRouter } from 'next/router';
import { Helmet } from 'react-helmet';
import Link from 'next/link';
import Error from 'next/error';
import {
Button,
Card,
CardDeck,
Container,
Nav,
Navbar,
NavDropdown,
Row,
ProgressBar,
} from 'react-bootstrap';
import TimeAgo from 'react-timeago';
import { ProjectIcon, ClockIcon } from '@primer/octicons-react';
import { getApiURL, sanitize } from 'lib/utils';
import {
getCurrentQuarter,
getNextQuarter,
getPrevQuarter,
parseProjectMeta,
} from 'lib/utils/projects';
import Engineer from 'components/Engineer';
import ActiveLink from 'components/ActiveLink';
export function projectSort(a, b) {
const goalTypeA = a.meta.goalType ? a.meta.goalType : 'unclassified';
const goalTypeB = b.meta.goalType ? b.meta.goalType : 'unclassified';
return goalTypeA < goalTypeB ? -1 : goalTypeA > goalTypeB ? 1 : 0;
}
// This function pre-computes counts and extracts metadata prior to the data being
// added to the state. This way we're not running the same code over and over again
// during the render.
function buildMetaData(projectData) {
const newProjectData = { ...projectData };
if (projectData.data) {
const augmentedProjects = newProjectData.data.organization.projects.nodes.map(
(project) => {
const [meta, updatedHTML] = parseProjectMeta(project.bodyHTML);
project.bodyHTML = updatedHTML;
const todoColumn = project.columns.edges.find((column) => {
return column.node.name === 'To do';
});
const inProgressColumn = project.columns.edges.find((column) => {
return column.node.name === 'In progress';
});
const doneColumn = project.columns.edges.find((column) => {
return column.node.name === 'Done';
});
const todoCount =
(todoColumn && parseInt(todoColumn.node.cards.totalCount, 10)) || 0;
const inProgressCount =
(inProgressColumn &&
parseInt(inProgressColumn.node.cards.totalCount, 10)) ||
0;
const doneCount =
(doneColumn && parseInt(doneColumn.node.cards.totalCount, 10)) || 0;
const totalCount = todoCount + inProgressCount + doneCount;
const donePerc = totalCount ? (100 / totalCount) * doneCount : 0;
const inProgressPerc = totalCount
? (100 / totalCount) * inProgressCount
: 0;
project.meta = {
...meta,
todoCount,
inProgressCount,
doneCount,
donePerc,
inProgressPerc,
};
return project;
},
);
newProjectData.data.organization.projects.nodes = augmentedProjects;
}
return newProjectData;
}
export async function getServerSideProps(props) {
const { year, quarter } = props.params;
const projectURL = getApiURL('/api/gh-projects/', { quarter, year });
const teamURL = getApiURL('/api/gh-team/');
const [projectResponse, teamResponse] = await Promise.all([
fetch(projectURL),
fetch(teamURL),
]);
const errorCode = projectResponse.ok ? false : projectResponse.status;
const projects = await projectResponse.json();
const team = await teamResponse.json();
return {
props: {
errorCode,
projects: buildMetaData(projects),
team,
},
};
}
const Projects = (props) => {
if (props.errorCode) {
return <Error statusCode={props.errorCode} />;
}
const router = useRouter();
const { year, quarter, projectType, engineer } = router.query;
const projectURL = getApiURL('/api/gh-projects/', { quarter, year });
const teamData = props.team;
const initialProjectsData = props.projects;
const { data: projectsData } = useSWR(
projectURL,
async () => {
const result = await fetch(projectURL);
const json = await result.json();
return buildMetaData(json);
},
{ initialData: initialProjectsData, refreshInterval: 30000 },
);
let projects = null;
let currentProjectType = null;
if (projectsData.data) {
projectsData.data.organization.projects.nodes.sort(projectSort);
projects = projectsData.data.organization.projects.nodes.map((project) => {
if (projectType && projectType !== null) {
if (!RegExp(projectType, 'i').test(project.meta.goalType)) {
return null;
}
}
const teamMembers = [];
if (teamData.data) {
for (const eng of project.meta.engineers) {
const teamMembersToSearch = [
...teamData.data.organization.team.members.nodes,
...teamData.data.organization.outreachy.members.nodes,
];
const foundMember = teamMembersToSearch.find((item) => {
return item.login.toLowerCase() === eng.toLowerCase();
});
if (foundMember) {
teamMembers.push(foundMember);
}
}
}
if (
engineer &&
project.meta.engineers &&
!project.meta.engineers.includes(engineer)
) {
return null;
}
const projectNode = (
<div className="card-wrapper" key={project.name}>
{!projectType && currentProjectType === null ? (
<h2 className="project-type">Primary</h2>
) : null}
{!projectType &&
currentProjectType === 'primary' &&
project.meta.goalType === 'secondary' ? (
<h2 className="project-type">Secondary</h2>
) : null}
<Card bg={project.meta.goalType === 'primary' ? 'muted' : 'light'}>
<Card.Header as="h2">
<span>
<ProjectIcon verticalAlign="middle" />
{project.name}
</span>
<div>
{teamMembers.map((member) => {
return (
<Engineer
key={member.login + project.name}
member={member}
project={project}
year={year}
quarter={quarter}
/>
);
})}
</div>
</Card.Header>
<ProgressBar>
<ProgressBar
variant="success"
now={project.meta.donePerc}
key={1}
/>
<ProgressBar
variant="warning"
now={project.meta.inProgressPerc}
key={2}
/>
</ProgressBar>
<Card.Body
dangerouslySetInnerHTML={{
__html: sanitize(project.bodyHTML),
}}
/>
<Card.Footer bg="light">
<span className="updated float-left">
<ClockIcon /> Updated&nbsp;
<TimeAgo date={project.updatedAt} />
</span>
<Button
href={project.url}
size="sm"
className="float-right"
variant="outline-primary"
target="_blank"
rel="noreferrer noopener"
>
View Project
</Button>
</Card.Footer>
</Card>
<br />
</div>
);
currentProjectType = project.meta.goalType;
return projectNode;
});
}
// Filter null elements. This avoids a case where we have
// a projects list that looks like [null, null].
if (projects && projects.length) {
projects = projects.filter((el) => {
return el !== null;
});
}
const { year: currentYear, quarter: currentQuarter } = getCurrentQuarter();
const { year: prevYear, quarter: prevQuarter } = getPrevQuarter({
year,
quarter,
});
const { year: nextYear, quarter: nextQuarter } = getNextQuarter({
year,
quarter,
});
return (
<div>
<Helmet>
<title>Projects</title>
</Helmet>
<Navbar
variant="muted"
bg="light"
className="shadow-sm d-flex justify-content-between"
sticky="top"
>
<Nav variant="pills">
<Nav.Item>
<Link href={`/projects/${prevYear}/${prevQuarter}/`} passHref>
<Nav.Link eventKey="prev" className="previous" active={false}>
Previous
</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={`/projects/${nextYear}/${nextQuarter}/`} passHref>
<Nav.Link eventKey="next" className="next" active={false}>
Next
</Nav.Link>
</Link>
</Nav.Item>
</Nav>
<Nav variant="pills">
<Nav.Item>
<ActiveLink
href={`/projects/${currentYear}/${currentQuarter}/`}
activeClassName="active"
passHref
>
<Nav.Link eventKey="current">Current Quarter</Nav.Link>
</ActiveLink>
</Nav.Item>
<NavDropdown className="filters" title="Filters" alignRight>
<Link href={`/projects/${year}/${quarter}/`} passHref>
<NavDropdown.Item eventKey="all">All</NavDropdown.Item>
</Link>
<Link
href={`/projects/${year}/${quarter}/?projectType=primary`}
passHref
>
<NavDropdown.Item eventKey="primary">Primary</NavDropdown.Item>
</Link>
<Link
href={`/projects/${year}/${quarter}/?projectType=secondary`}
passHref
>
<NavDropdown.Item eventKey="secondary">
Secondary
</NavDropdown.Item>
</Link>
</NavDropdown>
</Nav>
</Navbar>
<Container fluid>
<Row>
<Container as="main" bg="light">
<h1>
Projects for {quarter} {year}{' '}
{projectType
? `(${projectType})`
: engineer
? `(${engineer})`
: '(All)'}
</h1>
{projectsData.data === null ? <p>Loading...</p> : null}
{projects && projects.length ? (
<CardDeck>{projects}</CardDeck>
) : projects && projects.length === 0 ? (
<p>There are no Projects available for this quarter yet</p>
) : null}
</Container>
</Row>
</Container>
</div>
);
};
export default Projects;

17
pages/projects/latest.js Normal file
Просмотреть файл

@ -0,0 +1,17 @@
import { getCurrentQuarter } from 'lib/utils/projects';
export default function Page() {
return null;
}
export async function getServerSideProps() {
const { year, quarter } = getCurrentQuarter();
const destination = `/projects/${year}/${quarter}/`;
return {
redirect: {
permanent: false,
destination,
},
};
}

Просмотреть файл

@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Add-ons PM</title>
</head>
<body class="bg-light">
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
</body>
</html>

2
public/robots.txt Normal file
Просмотреть файл

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

Просмотреть файл

До

Ширина:  |  Высота:  |  Размер: 32 KiB

После

Ширина:  |  Высота:  |  Размер: 32 KiB

Просмотреть файл

@ -1,154 +0,0 @@
import React from 'react';
import './App.scss';
import { Nav, Navbar } from 'react-bootstrap';
import { IndexLinkContainer, LinkContainer } from 'react-router-bootstrap';
import {
BrowserRouter as Router,
Route,
Redirect,
Switch,
} from 'react-router-dom';
import { Helmet } from 'react-helmet';
import Octicon, { MarkGithub } from '@githubprimer/octicons-react';
import Home from './Home';
import DashboardAMO from './DashboardAMO';
import DashboardWE from './DashboardWE';
import Projects from './Projects';
import GoodFirstBugs from './ContribGoodFirstBugs';
import MaybeGoodFirstBugs from './ContribMaybeGoodFirstBugs';
import ContribWelcome from './ContribWelcome';
import Milestones from './Milestones';
import NotFound from './NotFound';
import { validProjectTeamMembers, validYears } from '../const';
const App = () => {
return (
<Router>
<div>
<Helmet defaultTitle="Add-ons PM" titleTemplate="%s - Add-ons PM" />
<Navbar bg="dark" variant="dark">
<IndexLinkContainer to="/">
<Navbar.Brand>Addons PM</Navbar.Brand>
</IndexLinkContainer>
<Nav className="mr-auto">
<Nav.Item>
<LinkContainer to="/projects/latest/">
<Nav.Link eventKey={1}>Projects</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer
to="/milestones/latest/?dir=asc&sort=assignee"
isActive={(match, location) => {
return location.pathname.indexOf('/milestones') > -1;
}}
>
<Nav.Link eventKey={2}>Milestones</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer to="/dashboards/amo/">
<Nav.Link eventKey={3}>AMO Dashboard</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer to="/dashboards/webext/">
<Nav.Link eventKey={4}>Webext Dashboard</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer
to="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
isActive={(match, location) => {
return location.pathname.indexOf('/contrib') > -1;
}}
>
<Nav.Link eventKey={5}>Contributions</Nav.Link>
</LinkContainer>
</Nav.Item>
</Nav>
<Nav className="mr-sm-2">
<Nav.Item>
<Nav.Link
data-ref="src"
eventKey="src"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/mozilla/addons-pm/"
>
<Octicon
icon={MarkGithub}
verticalAlign="middle"
ariaLabel="View on Github"
size="medium"
/>
</Nav.Link>
</Nav.Item>
</Nav>
</Navbar>
<Switch>
<Route exact path="/" component={Home} />
<Route
exact
path={`/milestones/:year(${validYears.join(
'|',
)}).:month(0[1-9]|1[0-2]).:day(0[1-9]|[1-2]\\d|3[0-1])/`}
component={Milestones}
/>
<Route
exact
path="/milestones/:milestone(latest)/"
component={Milestones}
/>
<Redirect from="/dashboard/" to="/dashboards/amo/" />
<Route exact path="/dashboards/amo/" component={DashboardAMO} />
<Route exact path="/dashboards/webext/" component={DashboardWE} />
<Route
exact
path="/contrib/maybe-good-first-bugs/"
component={MaybeGoodFirstBugs}
/>
<Route
exact
path="/contrib/good-first-bugs/"
component={GoodFirstBugs}
/>
<Route
exact
path="/contrib/contrib-welcome/"
component={ContribWelcome}
/>
<Route exact path="/projects/:year(latest)/" component={Projects} />
<Route
exact
path={`/projects/:year(${validYears.join('|')})/:quarter(Q[1-4])/`}
component={Projects}
/>
<Route
exact
path={`/projects/:year(${validYears.join(
'|',
)})/:quarter(Q[1-4])/:projectType(primary|secondary)/`}
component={Projects}
/>
<Route
exact
path={`/projects/:year(${validYears.join(
'|',
)})/:quarter(Q[1-4])/:engineer(${validProjectTeamMembers.join(
'|',
)})/`}
component={Projects}
/>
<Route component={NotFound} />
</Switch>
</div>
</Router>
);
};
export default App;

Просмотреть файл

@ -1,272 +0,0 @@
body {
background: #f8f9fa !important;
min-width: 400px;
}
@media (min-width: 1500px) {
h1,
.h1 {
color: #666;
font-size: 2rem !important;
margin-top: 0.5em !important;
position: sticky;
text-align: center;
top: 10px;
z-index: 1040;
}
}
@media (min-width: 1200px) {
main.container {
max-width: 1360px;
}
}
nav[aria-label='breadcrumb'] {
margin-bottom: 10px;
min-width: auto;
}
.navbar.d-flex {
display: block !important;
}
@media (min-width: 900px) {
nav[aria-label='breadcrumb'] {
margin-bottom: 0;
min-width: auto;
}
.navbar.d-flex {
display: flex !important;
}
}
.App {
text-align: center;
}
.App-header {
height: 150px;
padding: 20px;
}
.card-wrapper,
.card {
width: 100%;
}
.card-header {
align-items: center;
display: flex;
font-size: 18px;
justify-content: space-between;
}
h2.card-header {
color: #333;
}
.card-header svg {
margin-bottom: 4px;
margin-right: 8px;
}
.navbar h1,
.navbar h2 {
font-size: 18px;
margin-bottom: 0;
}
.card .progress {
width: 100%;
}
.card .progress,
.card .progressbar {
border-radius: 0;
height: 8px;
}
.updated {
color: #666;
font-size: 14px;
line-height: 2;
}
.eng-image {
border: 2px solid #ccc;
box-sizing: content-box;
margin-left: 5px;
}
.sidebar {
bottom: 0;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1);
left: 0;
padding: 25px 0 0 0; /* Height of navbar */
position: sticky;
top: 25px;
z-index: -1; /* Behind the navbar */
}
.sidebar-sticky {
height: calc(100vh - 50px);
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
padding-top: 0.5rem;
top: 0;
}
.sidebar .nav-link {
color: #333;
font-weight: 500;
}
.sidebar .nav-link .feather {
color: #999;
margin-right: 4px;
}
.sidebar .nav-link.active {
color: #007bff;
}
.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}
.sidebar-heading {
font-size: 0.75rem;
text-transform: uppercase;
}
nav .breadcrumb {
margin-bottom: 0;
}
.navbar-brand svg {
margin-bottom: 4px;
}
.project-type {
color: #666;
font-size: 22px;
margin: 20px 0 15px 15px;
}
div.table-responsive {
overflow-x: unset;
}
table.table {
position: relative;
thead th {
background: #f8f9fa;
position: sticky;
top: 55px;
&::after {
border-bottom: 2px solid #dee2e6;
bottom: -2px;
content: '';
left: 0;
position: absolute;
width: 100%;
}
}
}
th a {
&:link,
&:visited,
&:focus,
&:active {
color: #000;
}
&:hover {
text-decoration: none;
}
&.sort-direction {
overflow: hidden;
white-space: nowrap;
&::after {
border-left: 5px solid transparent;
border-right: 5px solid transparent;
content: '';
display: inline-block;
margin: 0 0 2px 5px;
}
&.asc::after {
border-bottom: 5px solid black;
}
&.desc::after {
border-top: 5px solid black;
}
}
}
.p1,
.p2,
.p3,
.p4,
.p5,
.unprioritized {
background: #999;
border-radius: 3px;
color: #fff;
font-size: 12px;
padding: 5px 5px 3px;
}
.p1 {
background: #ff0039;
}
.p2 {
background: #d70022;
}
.p3 {
background: #a4000f;
}
.p4 {
background: #5a0002;
}
.p5 {
background: #3e0200;
}
.no,
.yes {
background: green;
border-radius: 3px;
color: #fff;
font-size: 12px;
padding: 5px 5px 3px;
}
.no {
background: orange;
}
table {
border: 0;
margin-bottom: 20px;
margin-top: 30px;
max-width: 100%;
width: 100%;
}
td.centered {
text-align: center;
}

Просмотреть файл

@ -1,9 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

Просмотреть файл

@ -1,142 +0,0 @@
import {
API_ROOT,
validQuarterRX,
validYearRX,
validMilestoneRX,
} from '../const';
async function getProjects(year, quarter) {
if (!validYearRX.test(year)) {
throw new Error('Invalid Year');
}
if (!validQuarterRX.test(quarter)) {
throw new Error('Invalid Quarter');
}
const response = await fetch(
`${API_ROOT}/projects/?year=${year}&quarter=${quarter}`,
{
headers: new Headers({
'Content-Type': 'application/json',
}),
},
);
checkStatus(response);
return parseJSON(response);
}
async function getMilestoneIssues(milestone) {
if (!validMilestoneRX.test(milestone)) {
throw new Error('Invalid Milestone');
}
const response = await fetch(
`${API_ROOT}/milestone-issues/?milestone=${milestone}`,
{
headers: new Headers({
'Content-Type': 'application/json',
}),
},
);
checkStatus(response);
return parseJSON(response);
}
async function getTeam() {
const response = await fetch(`${API_ROOT}/team/`, {
headers: new Headers({
'Content-Type': 'application/json',
}),
});
checkStatus(response);
return parseJSON(response);
}
async function getGithubIssueCounts() {
const response = await fetch(`${API_ROOT}/github-issue-counts/`, {
headers: new Headers({
'Content-Type': 'application/json',
}),
});
checkStatus(response);
return parseJSON(response);
}
async function getBugzillaIssueCounts() {
const response = await fetch(`${API_ROOT}/bugzilla-issue-counts/`, {
headers: new Headers({
'Content-Type': 'application/json',
}),
});
checkStatus(response);
return parseJSON(response);
}
async function getBugzillaNeedInfos() {
const response = await fetch(`${API_ROOT}/bugzilla-need-infos/`, {
headers: new Headers({
'Content-Type': 'application/json',
}),
});
checkStatus(response);
return parseJSON(response);
}
async function getGoodFirstBugs() {
const response = await fetch(`${API_ROOT}/good-first-bugs/`, {
headers: new Headers({
'Content-Type': 'application/json',
}),
});
checkStatus(response);
return parseJSON(response);
}
async function getContribWelcome() {
const response = await fetch(`${API_ROOT}/contrib-welcome/`, {
headers: new Headers({
'Content-Type': 'application/json',
}),
});
checkStatus(response);
return parseJSON(response);
}
async function getMaybeGoodFirstBugs() {
const response = await fetch(`${API_ROOT}/maybe-good-first-bugs/`, {
headers: new Headers({
'Content-Type': 'application/json',
}),
});
checkStatus(response);
return parseJSON(response);
}
function checkStatus(response) {
if (response.status >= 200 && response.status < 300) {
return response;
}
const error = new Error(`HTTP Error ${response.statusText}`);
error.status = response.statusText;
error.response = response;
throw error;
}
function parseJSON(response) {
return response.json();
}
const Client = {
checkStatus,
getProjects,
getTeam,
getGithubIssueCounts,
getBugzillaIssueCounts,
getBugzillaNeedInfos,
getContribWelcome,
getGoodFirstBugs,
getMaybeGoodFirstBugs,
getMilestoneIssues,
};
export default Client;

Просмотреть файл

@ -1,43 +0,0 @@
import Client from './Client';
describe('Client.getProjects()', () => {
it('throws if input year is bad', async () => {
try {
await Client.getProjects('bad-year', 'Q3');
} catch (e) {
expect(e.message).toMatch('Invalid Year');
}
});
it('throws if input quarter is bad', async () => {
try {
await Client.getProjects('2018', 'Q20');
} catch (e) {
expect(e.message).toMatch('Invalid Quarter');
}
});
});
describe('Client.checkStatus()', () => {
it('throws if response status is not 200', () => {
const response = new Response('FAIL', {
status: 500,
statusText: 'soz',
});
try {
Client.checkStatus(response);
} catch (e) {
expect(e.message).toMatch(/soz/);
}
});
});
describe('Client.getMilestones()', () => {
it('throws if milestone is invalie', async () => {
try {
await Client.getMilestoneIssues('whatever');
} catch (e) {
expect(e.message).toMatch('Invalid Milestone');
}
});
});

Просмотреть файл

@ -1,309 +0,0 @@
import React, { Component } from 'react';
import { Helmet } from 'react-helmet';
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
import TimeAgo from 'react-timeago';
import queryString from 'query-string';
import Octicon, { Alert, Link } from '@githubprimer/octicons-react';
import { LinkContainer } from 'react-router-bootstrap';
import {
alphaSort,
dateSort,
numericSort,
hasLabelContainingString,
} from './utils';
import { priorities } from '../const';
import './Contrib.scss';
// These views display assigment columns.
const dataKeysWithAssignment = ['goodFirstBugs', 'contribWelcome'];
class BaseContrib extends Component {
formatData(data) {
let issues = [];
data.forEach((item) => {
const issue = {
...item.issue,
priority: '',
assigned: false,
mentorAssigned: false,
};
const labels = issue.labels.nodes || [];
priorities.forEach((priority) => {
if (hasLabelContainingString(labels, priority)) {
issue.priority = priority;
}
});
if (hasLabelContainingString(labels, 'contrib: assigned')) {
issue.assigned = true;
}
if (hasLabelContainingString(labels, 'contrib: mentor assigned')) {
issue.mentorAssigned = true;
}
if (issue.repository && issue.repository.name) {
issue.repo = issue.repository.name;
}
issues.push(issue);
});
return issues;
}
setArrow(column) {
const { location } = this.props;
const qs = queryString.parse(location.search);
let className = 'sort-direction';
if (qs.sort === column) {
className = `${className} ${qs.dir === 'asc' ? ' asc' : ' desc'}`;
}
return className;
}
sortConfig = {
priority: {},
title: {},
repo: {},
assigned: {
sortFunc: numericSort,
},
mentorAssigned: {
sortFunc: numericSort,
},
updatedAt: {
sortFunc: dateSort,
},
};
sortData({ dataKey, columnKey, direction } = {}) {
const data = this.state[dataKey];
if (!data) {
console.debug('No data yet, bailing');
return data;
}
if (!Object.keys(this.sortConfig).includes(columnKey)) {
console.debug(
`"${columnKey}" does not match one of "${Object.keys(
this.sortConfig,
).join(', ')}"`,
);
return data;
}
if (!['desc', 'asc'].includes(direction)) {
console.debug(`"${direction}" does not match one of 'asc' or 'desc'`);
return data;
}
const sortFunc = this.sortConfig[columnKey].sortFunc || alphaSort;
const sorted = [].concat(data).sort(sortFunc(columnKey));
// Reverse for desc.
if (direction === 'desc') {
sorted.reverse();
}
return sorted;
}
renderMentorAssigned(issue) {
let mentorAssigned = <span className="mentor not-assigned">NO</span>;
if (issue.mentorAssigned) {
mentorAssigned = <span className="mentor">YES</span>;
}
return mentorAssigned;
}
renderContribAssigned(issue) {
let assigned = <span className="contributor not-assigned">NO</span>;
if (issue.assigned) {
assigned = <span className="contributor">YES</span>;
}
return assigned;
}
renderHeaderLink(column, name) {
const { location } = this.props;
const qs = queryString.parse(location.search);
const sort = qs.sort;
let linkDir = 'desc';
let classDir = qs.dir === 'asc' ? 'asc' : 'desc';
let className = 'sort-direction';
if (sort === column) {
linkDir = qs.dir === 'desc' ? 'asc' : 'desc';
className = `${className} ${classDir}`;
}
const query = `?${queryString.stringify({
...qs,
dir: linkDir,
sort: column,
})}`;
return (
<LinkContainer to={query}>
<a className={className} href={query}>
{name}
</a>
</LinkContainer>
);
}
renderRows(data) {
const rows = [];
const colSpan = 4;
if (!data) {
return (
<tr>
<td colSpan={colSpan}>Loading...</td>
</tr>
);
}
if (data.length === 0) {
return (
<tr>
<td colSpan={colSpan}>
<div className="not-found">
<p>
No issues found! Time to deploy the team to find some quality
bugs!
</p>
</div>
</td>
</tr>
);
}
for (var i = 0; i < data.length; i++) {
const issue = data[i] || {};
rows.push(
<tr key={`issue-${i}`}>
<td>
<span className={issue.priority || 'unprioritized'}>
{issue.priority ? (
issue.priority.toUpperCase()
) : (
<Octicon icon={Alert} />
)}
</span>
</td>
<td>
<a href={issue.url} target="_blank" rel="noopener noreferrer">
<strong>#{issue.number}:</strong> {issue.title}{' '}
<Octicon icon={Link} verticalAlign="middle" />
</a>
</td>
<td>{issue.repository.name.replace('addons-', '')}</td>
{dataKeysWithAssignment.includes(this.state.dataKey) ? (
<td className="centered">{this.renderContribAssigned(issue)}</td>
) : null}
{dataKeysWithAssignment.includes(this.state.dataKey) ? (
<td className="centered">{this.renderMentorAssigned(issue)}</td>
) : null}
<td>
<TimeAgo date={issue.updatedAt} />
</td>
</tr>,
);
}
return rows;
}
render() {
const { location } = this.props;
const qs = queryString.parse(location.search);
let data;
const dataKey = this.state.dataKey;
data = this.state[dataKey];
if (qs.sort) {
data = this.sortData({ dataKey, columnKey: qs.sort, direction: qs.dir });
}
return (
<div className="Contrib">
<Helmet>
<title>Contributions</title>
</Helmet>
<Navbar
variant="muted"
bg="light"
className="shadow-sm d-flex justify-content-between"
sticky="top"
>
<Nav variant="pills">
<Nav.Item>
<LinkContainer
to="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
isActive={(match, location) => {
return (
location.pathname.indexOf(
'/contrib/maybe-good-first-bugs',
) > -1
);
}}
exact
>
<Nav.Link eventKey="mgfb">Maybe Good First Bugs</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer
to="/contrib/good-first-bugs/?dir=desc&sort=updatedAt"
isActive={(match, location) => {
return (
location.pathname.indexOf('/contrib/good-first-bugs') > -1
);
}}
exact
>
<Nav.Link eventKey="gfb">Good First Bugs</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer
to="/contrib/contrib-welcome/?dir=desc&sort=updatedAt"
isActive={(match, location) => {
return (
location.pathname.indexOf('/contrib/contrib-welcome') > -1
);
}}
exact
>
<Nav.Link eventKey="cw">Contrib Welcome</Nav.Link>
</LinkContainer>
</Nav.Item>
</Nav>
</Navbar>
<Container as="main" bg="light">
<Table responsive hover>
<thead>
<tr>
<th>{this.renderHeaderLink('priority', 'Priority')}</th>
<th>{this.renderHeaderLink('title', 'Issue')}</th>
<th className="repo">
{this.renderHeaderLink('repo', 'Repo')}
</th>
{dataKeysWithAssignment.includes(dataKey) ? (
<th>{this.renderHeaderLink('assigned', 'Assigned?')}</th>
) : null}
{dataKeysWithAssignment.includes(dataKey) ? (
<th>
{this.renderHeaderLink('mentorAssigned', 'Has Mentor?')}
</th>
) : null}
<th className="last-updated">
{this.renderHeaderLink('updatedAt', 'Last Update')}
</th>
</tr>
</thead>
<tbody>{this.renderRows(data)}</tbody>
</Table>
</Container>
</div>
);
}
}
export default BaseContrib;

Просмотреть файл

@ -1,29 +0,0 @@
.Contrib {
.contributor,
.mentor {
background: green;
border-radius: 3px;
color: #fff;
font-size: 12px;
padding: 5px 5px 3px;
&.not-assigned {
background: orange;
}
}
.not-found {
background: url('images/blue-berror.svg') no-repeat 50% 50%;
min-height: 450px;
padding-top: 10px;
text-align: center;
}
.last-updated {
min-width: 9em;
}
th.repo {
width: 11em;
}
}

Просмотреть файл

@ -1,82 +0,0 @@
/* global testData */
import React from 'react';
import GoodFirstBugs from './ContribGoodFirstBugs';
import MaybeGoodFirstBugs from './ContribMaybeGoodFirstBugs';
import ContribWelcome from './ContribWelcome';
import { mount } from 'enzyme';
import fetchMock from 'fetch-mock';
import { MemoryRouter } from 'react-router-dom';
describe('Contributions', () => {
beforeEach(() => {
fetchMock.mock(/\/api\/good-first-bugs\//, testData.goodFirstBugs);
fetchMock.mock(
/\/api\/maybe-good-first-bugs\//,
testData.maybeGoodFirstBugs,
);
fetchMock.mock(/\/api\/contrib-welcome\//, testData.contribWelcome);
});
afterEach(() => {
fetchMock.restore();
});
it('should render some good first bugs', async () => {
const fakeLocation = {
pathname: '/contributions/good-first-bugs/',
};
const wrapper = mount(
<MemoryRouter>
<GoodFirstBugs location={fakeLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('GoodFirstBugs').instance();
await instance.componentDidMount();
wrapper.update();
const issueData = instance.state.goodFirstBugs;
const expectedNumberOfRows = Object.keys(issueData).length;
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
});
it('should render some maybe good first bugs', async () => {
const fakeLocation = {
pathname: '/contributions/maybe-good-first-bugs/',
};
const wrapper = mount(
<MemoryRouter>
<MaybeGoodFirstBugs location={fakeLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('MaybeGoodFirstBugs').instance();
await instance.componentDidMount();
wrapper.update();
const issueData = instance.state.maybeGoodFirstBugs;
const expectedNumberOfRows = Object.keys(issueData).length;
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
});
it('should render some contrib welcome bugs', async () => {
const fakeLocation = {
pathname: '/contributions/contrib-welcome/',
};
const wrapper = mount(
<MemoryRouter>
<ContribWelcome location={fakeLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('ContribWelcome').instance();
await instance.componentDidMount();
wrapper.update();
const issueData = instance.state.contribWelcome;
const expectedNumberOfRows = Object.keys(issueData).length;
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
});
});

Просмотреть файл

@ -1,20 +0,0 @@
import BaseContrib from './Contrib';
import Client from './Client';
class GoodFirstBugs extends BaseContrib {
state = {
goodFirstBugs: null,
dataKey: 'goodFirstBugs',
};
async componentDidMount() {
const goodFirstBugs = await Client.getGoodFirstBugs();
this.setState({
goodFirstBugs: this.formatData(
goodFirstBugs.data.good_first_bugs.results,
),
});
}
}
export default GoodFirstBugs;

Просмотреть файл

@ -1,20 +0,0 @@
import BaseContrib from './Contrib';
import Client from './Client';
class MaybeGoodFirstBugs extends BaseContrib {
state = {
maybeGoodFirstBugs: null,
dataKey: 'maybeGoodFirstBugs',
};
async componentDidMount() {
const maybeGoodFirstBugs = await Client.getMaybeGoodFirstBugs();
this.setState({
maybeGoodFirstBugs: this.formatData(
maybeGoodFirstBugs.data.maybe_good_first_bugs.results,
),
});
}
}
export default MaybeGoodFirstBugs;

Просмотреть файл

@ -1,20 +0,0 @@
import BaseContrib from './Contrib';
import Client from './Client';
class ContribWelcome extends BaseContrib {
state = {
contribWelcome: null,
dataKey: 'contribWelcome',
};
async componentDidMount() {
const contribWelcome = await Client.getContribWelcome();
this.setState({
contribWelcome: this.formatData(
contribWelcome.data.contrib_welcome.results,
),
});
}
}
export default ContribWelcome;

Просмотреть файл

@ -1,65 +0,0 @@
.dash {
background: #1e2430 !important;
.loading {
color: #fff;
text-align: center;
}
}
.dashboard .container {
max-width: 100%;
padding: 0.2rem 0.6rem;
}
.card-grp {
display: flex;
margin: 0.6rem 0;
&:first-of-type,
&:last-of-type {
margin: 0.6rem 0;
}
.card {
border-radius: 0;
margin: 0;
max-height: 100%;
&:focus {
z-index: 100;
}
&:hover {
text-decoration: none !important;
}
&:first-child {
border-bottom-left-radius: 0.25rem;
border-top-left-radius: 0.25rem;
}
&:last-child {
border-bottom-right-radius: 0.25rem;
border-top-right-radius: 0.25rem;
}
}
.card-header {
color: #ccc;
font-weight: 200;
text-shadow: -1px -1px rgba(0, 0, 0, 0.8);
}
.repo-card .card-header {
font-weight: 400;
}
}
.card svg text {
font-size: 2vw;
}
.card-grp.needinfos .card {
background: #38383d !important;
}

Просмотреть файл

@ -1,69 +0,0 @@
import React, { Component } from 'react';
import { Helmet } from 'react-helmet';
import { Container } from 'react-bootstrap';
import Client from './Client';
import AMODashCountGroup from './components/AMODashCountGroup';
import './Dashboard.scss';
class DashboardAMO extends Component {
state = {
issueCounts: {
data: null,
},
};
async getIssueCounts() {
return await Client.getGithubIssueCounts();
}
async componentDidMount() {
const issueCounts = await this.getIssueCounts();
this.setState({
issueCounts: issueCounts,
});
}
renderCounts() {
const issueCountData = this.state.issueCounts.data;
const countGroups = [];
Object.keys(issueCountData).forEach((repo, index) => {
countGroups.push(
<AMODashCountGroup
key={repo + index}
issueCounts={issueCountData[repo]}
description={issueCountData[repo].description}
repo={repo}
/>,
);
});
return countGroups;
}
render() {
const issueCounts = this.state.issueCounts;
return (
<div className="dashboard">
<Helmet>
<body className="dash" />
<title>AMO Dashboard</title>
</Helmet>
<Container as="main">
<div className="dash-container">
{issueCounts.data === null ? (
<div className="loading">
<p>Loading...</p>
</div>
) : (
this.renderCounts()
)}
</div>
</Container>
</div>
);
}
}
export default DashboardAMO;

Просмотреть файл

@ -1,37 +0,0 @@
/* global testData */
import React from 'react';
import DashboardAMO from './DashboardAMO';
import { mount } from 'enzyme';
import fetchMock from 'fetch-mock';
import { MemoryRouter } from 'react-router-dom';
const fakeLocation = {
pathname: '/dashboards/amo/',
};
describe('Dashboard', () => {
beforeEach(() => {
fetchMock.mock(/\/api\/github-issue-counts\//, testData.githubIssueCounts);
});
afterEach(() => {
fetchMock.restore();
});
it('should render some projects', async () => {
const wrapper = mount(
<MemoryRouter>
<DashboardAMO location={fakeLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('DashboardAMO').instance();
await instance.componentDidMount();
wrapper.update();
const issueData = instance.state.issueCounts;
const expectedNumberOfDashGroups = Object.keys(issueData.data).length;
expect(wrapper.find('.card-grp')).toHaveLength(expectedNumberOfDashGroups);
});
});

Просмотреть файл

@ -1,41 +0,0 @@
/* global testData */
import React from 'react';
import { render } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import DashboardWE from './DashboardWE';
const fakeLocation = {
pathname: '/dashboards/webext/',
};
describe('Webext Dashboard', () => {
beforeEach(() => {
fetchMock.mock(
/\/api\/bugzilla-issue-counts\//,
testData.bugzillaIssueCountsLocal,
);
fetchMock.mock(
/\/api\/bugzilla-need-infos\//,
testData.bugzillaNeedsInfoLocal,
);
});
afterEach(() => {
fetchMock.restore();
});
it('should render the webextension dashboard groups', async () => {
const { findAllByText } = render(<DashboardWE location={fakeLocation} />);
// All the dashgroups.
const cardGroups = await findAllByText(/.*?/, { selector: '.card-grp' });
expect(cardGroups).toHaveLength(4);
// The needinfo group.
const needInfos = await findAllByText(/.*?/, {
selector: '.card-grp.needinfos',
});
expect(needInfos).toHaveLength(1);
});
});

Просмотреть файл

@ -1,156 +0,0 @@
import React, { Component } from 'react';
import { Helmet } from 'react-helmet';
import { Button, Card, Col, Container, Row } from 'react-bootstrap';
import { LinkContainer } from 'react-router-bootstrap';
import Octicon, {
Dashboard,
Milestone,
Organization,
Project,
} from '@githubprimer/octicons-react';
class Home extends Component {
render() {
return (
<div>
<Helmet>
<title>Home Page</title>
</Helmet>
<br />
<Container as="main" className="home">
<Card>
<Card.Header>Projects</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<Octicon icon={Project} size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
This view shows our current projects, in-progress for the
AMO team, plus you can navigate to previous and future
quarters.
</p>
<p>
Each project's data is provided by Github's API, and this
view is a way to provide an overview of the projects per
quarter for just our team.
</p>
<LinkContainer to="/projects/latest/" exact>
<Button
href="/projects/latest/"
variant="outline-primary"
>
View Projects
</Button>
</LinkContainer>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
<Card>
<Card.Header>Milestones</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<Octicon icon={Milestone} size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
Milestones are used to provide an overview of what we're
shipping each week. You can also navigate to previous and
next milestones.
</p>
<p>
Each week we review what we're shipping with the current
milestone and preview what's being worked on for the
following week as part of our weekly Engineering stand-up.
</p>
<LinkContainer to="/milestones/latest/" exact>
<Button
href="/milestones/latest/"
variant="outline-primary"
>
View Milestones
</Button>
</LinkContainer>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
<Card>
<Card.Header>Dashboard</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<Octicon icon={Dashboard} size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
This tool is used to give us an overview of what the issue
counts look like and highlights any high priority bugs
</p>
<LinkContainer to="/dashboards/amo/" exact>
<Button href="/dashboards/amo/" variant="outline-primary">
View AMO Dashboard
</Button>
</LinkContainer>
<LinkContainer to="/dashboards/webext/" exact>
<Button
href="/dashboards/webext/"
variant="outline-primary"
>
View Web-Extensions Dashboard
</Button>
</LinkContainer>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
<Card>
<Card.Header>Contributions</Card.Header>
<Card.Body>
<Row>
<Col md="auto">
<Octicon icon={Organization} size="large" />
</Col>
<Col>
<Card.Text as="div">
<p>
This view shows bugs that might be suitable for a
contributor to work on, this data is used as part of the
bi-weekly contributor bug review.
</p>
<LinkContainer
to="/contrib/maybe-good-first-bugs/?dir=desc&sort=updatedAt"
exact
>
<Button
href="/contrib/maybe-good-first-bugs/?dir=d3esc&sort-updatedAt"
variant="outline-primary"
>
View Contributions
</Button>
</LinkContainer>
</Card.Text>
</Col>
</Row>
</Card.Body>
</Card>
</Container>
</div>
);
}
}
export default Home;

Просмотреть файл

@ -1,524 +0,0 @@
import React, { Component } from 'react';
import { Helmet } from 'react-helmet';
import { Container, Nav, Navbar, Table } from 'react-bootstrap';
import TimeAgo from 'react-timeago';
import queryString from 'query-string';
import Octicon, {
Alert,
Link,
Heart,
Person,
} from '@githubprimer/octicons-react';
import { LinkContainer } from 'react-router-bootstrap';
import { getMilestonePagination, getNextMilestone } from './utils';
import Client from './Client';
import {
alphaSort,
colourIsLight,
dateSort,
hasLabelContainingString,
hasLabel,
} from './utils';
import { colors, priorities } from '../const';
import './Milestones.scss';
const defaultSort = 'assignee';
const defaultSortDir = 'asc';
class Milestones extends Component {
state = {
milestoneIssues: null,
pagination: {},
};
formatData(data) {
let issues = [];
data.forEach((item) => {
const issue = {
...item.issue,
priority: null,
hasProject: false,
isContrib: false,
assignee: '00_unassigned',
};
const labels = issue.labels.nodes || [];
priorities.forEach((priority) => {
if (hasLabelContainingString(labels, priority)) {
issue.priority = priority;
}
});
if (hasLabelContainingString(labels, 'contrib: assigned')) {
issue.isContrib = true;
issue.assignee = '01_contributor';
}
if (issue.repository && issue.repository.name) {
issue.repo = issue.repository.name;
}
if (issue.projectCards.nodes.length) {
issue.hasProject = true;
issue.projectUrl = issue.projectCards.nodes[0].project.url;
issue.projectName = issue.projectCards.nodes[0].project.name;
}
issue.stateLabel = issue.state.toLowerCase();
issue.stateLabelColor =
issue.state === 'CLOSED' ? colors.closed : colors.open;
if (
issue.state === 'OPEN' &&
hasLabel(labels, 'state: pull request ready')
) {
issue.stateLabel = 'PR ready';
issue.stateLabelColor = colors.prReady;
} else if (
issue.state === 'OPEN' &&
hasLabel(labels, 'state: in progress')
) {
issue.stateLabel = 'in progress';
issue.stateLabelColor = colors.inProgress;
} else if (
issue.state === 'CLOSED' &&
hasLabel(labels, 'state: verified fixed')
) {
issue.stateLabel = 'verified fixed';
issue.stateLabelColor = colors.verified;
} else if (
issue.state === 'CLOSED' &&
hasLabel(labels, 'qa: not needed')
) {
issue.stateLabel = 'closed QA-';
issue.stateLabelColor = colors.verified;
}
issue.stateLabelTextColor = colourIsLight(issue.stateLabelColor)
? '#000'
: '#fff';
if (issue.assignees.nodes.length) {
issue.assignee = issue.assignees.nodes[0].login;
}
issue.reviewers = [];
const reviewersListSeen = [];
if (issue.state === 'CLOSED') {
issue.timelineItems.edges.forEach((item) => {
if (!item.event.source.reviews) {
// This is not a pull request item.
return;
}
const bodyText = item.event.source.bodyText;
const issueTestRx = new RegExp(`Fix(?:es)? #${issue.number}`, 'i');
// Only add the review if the PR contains a `Fixes #num` or `Fix #num` line that
// matches the original issue.
if (issueTestRx.test(bodyText)) {
item.event.source.reviews.edges.forEach(
({ review: { author } }) => {
if (!reviewersListSeen.includes(author.login)) {
reviewersListSeen.push(author.login);
issue.reviewers.push({
author,
prLink: item.event.source.permalink,
});
}
},
);
}
});
}
// Quick and dirty way to provide a sortable key for reviewers.
issue.reviewersNames = '';
if (issue.reviewers.length) {
issue.reviewersNames = issue.reviewers
.map((item) => item.author.login)
.join('-');
}
issues.push(issue);
});
return issues;
}
renderBoolAsYesOrNo(issue, prop) {
let comp = <span className="no">NO</span>;
if (issue[prop] === true) {
comp = <span className="yes">YES</span>;
}
return comp;
}
setArrow(column) {
const { location } = this.props;
const qs = queryString.parse(location.search);
let className = 'sort-direction';
if (qs.sort === column) {
className = `${className} ${qs.dir === 'asc' ? ' asc' : ' desc'}`;
}
return className;
}
async componentDidUpdate(prevProps, prevState) {
await this.updateComponentData(prevProps, prevState);
}
async componentDidMount() {
await this.updateComponentData();
}
async updateComponentData(prevProps, prevState) {
const { match } = this.props;
const { milestone, year, month, day } = match.params;
// Check if we need to recalculate anything.
if (prevProps && prevProps.match) {
const prevMatch = prevProps.match;
if (
prevMatch.params.milestone === milestone &&
prevMatch.params.year === year &&
prevMatch.params.month === month &&
prevMatch.params.day === day
) {
console.log('No changes to milestone props, skipping update');
return;
}
}
// Scroll to top when new data loads.
window.scrollTo(0, 0);
let milestoneTag;
let milestonePagination;
if (milestone === 'latest') {
milestonePagination = getMilestonePagination({
startDate: getNextMilestone(),
});
milestoneTag = milestonePagination.current;
// Update the URL from '/milestones/latest/...' to the permalink URL.
this.props.history.push(
`/milestones/${
milestonePagination.current
}/${this.getCurrentSortQueryString()}`,
);
} else {
milestoneTag = `${year}.${month}.${day}`;
milestonePagination = getMilestonePagination({
startDate: new Date(year, month - 1, day),
});
}
const milestoneIssues = await Client.getMilestoneIssues(milestoneTag);
this.setState({
pagination: milestonePagination,
milestoneIssues: milestoneIssues.data
? this.formatData(milestoneIssues.data.milestone_issues.results)
: [],
});
}
sortConfig = {
assignee: {},
priority: {},
title: {},
repo: {},
updatedAt: {
sortFunc: dateSort,
},
hasProject: {},
state: {},
reviewersNames: {},
};
sortData({ columnKey, direction } = {}) {
const data = this.state.milestoneIssues;
if (!data) {
console.debug('No data yet, bailing');
return data;
}
if (!Object.keys(this.sortConfig).includes(columnKey)) {
console.debug(
`"${columnKey}" does not match one of "${Object.keys(
this.sortConfig,
).join(', ')}"`,
);
return data;
}
if (!['desc', 'asc'].includes(direction)) {
console.debug(`"${direction}" does not match one of 'asc' or 'desc'`);
return data;
}
const sortFunc = this.sortConfig[columnKey].sortFunc || alphaSort;
const sorted = [].concat(data).sort(sortFunc(columnKey));
// Reverse for desc.
if (direction === 'desc') {
sorted.reverse();
}
return sorted;
}
getCurrentSortQueryString() {
const { location } = this.props;
const qs = queryString.parse(location.search);
return `?${queryString.stringify({
dir: qs.dir || defaultSortDir,
sort: qs.sort || defaultSort,
})}`;
}
renderHeaderLink(column, name) {
const { location } = this.props;
const qs = queryString.parse(location.search);
const sort = qs.sort;
let linkDir = 'desc';
let classDir = qs.dir === 'asc' ? 'asc' : 'desc';
let className = 'sort-direction';
if (sort === column) {
linkDir = qs.dir === 'desc' ? 'asc' : 'desc';
className = `${className} ${classDir}`;
}
const query = `?${queryString.stringify({
dir: linkDir,
sort: column,
})}`;
return (
<LinkContainer to={query}>
<a className={className} href={query}>
{name}
</a>
</LinkContainer>
);
}
renderAssignee(issue) {
if (issue.assignees.nodes.length) {
const issueAssignee = issue.assignees.nodes[0];
return (
<span>
<img className="avatar" src={issueAssignee.avatarUrl} alt="" />{' '}
{issueAssignee.login}
</span>
);
} else if (issue.assignee === '01_contributor') {
return (
<span className="contributor">
<Octicon icon={Heart} verticalAlign="middle" /> Contributor
</span>
);
}
return (
<span className="unassigned">
<Octicon icon={Person} verticalAlign="middle" /> Unassigned
</span>
);
}
renderReviewers(issue) {
const reviewers = [];
issue.reviewers.forEach((item) => {
reviewers.push(
<React.Fragment key={`${issue.number}-${item.author.login}`}>
<a href={item.prLink} target="_blank" rel="noopener noreferrer">
<img
className="avatar"
src={item.author.avatarUrl}
title={`Reviewed by ${item.author.login}`}
alt=""
/>
</a>
</React.Fragment>,
);
});
return reviewers;
}
renderRows(data) {
const rows = [];
const colSpan = 7;
if (!data) {
return (
<tr>
<td colSpan={colSpan}>Loading...</td>
</tr>
);
}
if (data.length === 0) {
return (
<tr>
<td colSpan={colSpan}>
<p>There are no issues associated with this milestone yet.</p>
</td>
</tr>
);
}
for (var i = 0; i < data.length; i++) {
const issue = data[i] || {};
rows.push(
<tr key={`issue-${i}`}>
<td className="assignee">{this.renderAssignee(issue)}</td>
<td>
<span className={issue.priority || 'unprioritized'}>
{issue.priority ? (
issue.priority.toUpperCase()
) : (
<Octicon icon={Alert} />
)}
</span>
</td>
<td>
<a
className="issueLink"
href={issue.url}
target="_blank"
rel="noopener noreferrer"
>
<strong>#{issue.number}:</strong> {issue.title}{' '}
<Octicon icon={Link} verticalAlign="middle" />
</a>
{issue.hasProject ? (
<a
href={issue.projectUrl}
target="_blank"
rel="noopener noreferrer"
className="projectLink"
>
{issue.projectName}
</a>
) : null}
</td>
<td>{issue.repository.name.replace('addons-', '')}</td>
<td>
<TimeAgo date={issue.updatedAt} />
</td>
<td>
<span
className="label"
style={{
backgroundColor: issue.stateLabelColor,
color: issue.stateLabelTextColor,
}}
>
{issue.stateLabel}
</span>
</td>
<td className="reviewers">{this.renderReviewers(issue)}</td>
</tr>,
);
}
return rows;
}
render() {
const { location } = this.props;
const qs = queryString.parse(location.search);
const milestone = this.state.pagination.start || 'Loading...';
let data = this.state.milestoneIssues;
if (qs.sort) {
data = this.sortData({ columnKey: qs.sort, direction: qs.dir });
}
return (
<div className="Milestones">
<Helmet>
<title>Milestones</title>
</Helmet>
<Navbar
variant="muted"
bg="light"
className="shadow-sm d-flex justify-content-between"
sticky="top"
>
<Nav variant="pills">
<Nav.Item>
<LinkContainer
to={`/milestones/${
this.state.pagination.prevFromStart
}/${this.getCurrentSortQueryString()}`}
active={false}
exact
>
<Nav.Link eventKey="prev" className="previous">
Previous
</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer
to={`/milestones/${
this.state.pagination.nextFromStart
}/${this.getCurrentSortQueryString()}`}
active={false}
exact
>
<Nav.Link eventKey="next" className="next">
Next
</Nav.Link>
</LinkContainer>
</Nav.Item>
</Nav>
<Nav variant="pills">
<Nav.Item>
<LinkContainer
to={`/milestones/${
this.state.pagination.current
}/${this.getCurrentSortQueryString()}`}
isActive={(match, location) => {
return (
location.pathname.indexOf(this.state.pagination.current) >
-1 || location.pathname.indexOf('latest') > -1
);
}}
>
<Nav.Link eventKey="current" className="current">
Current Milestone
</Nav.Link>
</LinkContainer>
</Nav.Item>
</Nav>
</Navbar>
<Container as="main" bg="light">
<h1>Issues for milestone: {milestone}</h1>
<Table responsive hover>
<thead>
<tr>
<th className="assignees">
{this.renderHeaderLink('assignee', 'Assignee')}
</th>
<th>{this.renderHeaderLink('priority', 'Priority')}</th>
<th className="issue">
{this.renderHeaderLink('title', 'Issue')}
</th>
<th className="repo">
{this.renderHeaderLink('repo', 'Repo')}
</th>
<th className="last-updated">
{this.renderHeaderLink('updatedAt', 'Last Update')}
</th>
<th className="state">
{this.renderHeaderLink('state', 'State')}
</th>
<th>{this.renderHeaderLink('reviewersNames', 'Reviewers')}</th>
</tr>
</thead>
<tbody>{this.renderRows(data)}</tbody>
</Table>
</Container>
</div>
);
}
}
export default Milestones;

Просмотреть файл

@ -1,69 +0,0 @@
.Milestones {
th.assignees {
min-width: 10.5em;
}
th.repo {
min-width: 8em;
}
th.issue {
width: 45%;
}
h2 {
margin-top: 30px;
}
.label {
border-radius: 4px;
display: inline-block;
font-size: 13px;
font-weight: normal;
min-width: 8em;
padding: 5px;
text-align: center;
text-transform: capitalize;
}
.assignee,
.reviewers {
img,
svg {
border: 2px solid #ccc;
border-radius: 50%;
box-sizing: content-box;
display: inline-block;
height: 25px;
margin-bottom: 2px;
width: 25px;
}
.contributor svg {
box-sizing: border-box;
color: red;
height: 28px;
padding-top: 2px;
position: relative;
width: 28px;
}
}
.reviewers {
text-align: center;
}
.projectLink {
color: #666;
display: inline-block;
font-size: 13px;
svg {
box-sizing: border-box;
}
}
.issueLink {
display: block;
}
}

Просмотреть файл

@ -1,45 +0,0 @@
/* global testData */
import React from 'react';
import Milestones from './Milestones';
import { mount } from 'enzyme';
import fetchMock from 'fetch-mock';
import { MemoryRouter } from 'react-router-dom';
const fakeLocation = {
pathname: '/2019.04.25/',
};
const fakeMatch = {
params: {
year: '2019',
month: '04',
day: '25',
},
};
describe('Milestones', () => {
beforeEach(() => {
fetchMock.mock(/\/api\/milestone-issues\//, testData.milestoneIssues);
});
afterEach(() => {
fetchMock.restore();
});
it('should render a milestone table', async () => {
const wrapper = mount(
<MemoryRouter>
<Milestones location={fakeLocation} match={fakeMatch} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Milestones').instance();
await instance.componentDidMount();
wrapper.update();
const issueData = instance.state.milestoneIssues;
const expectedNumberOfRows = Object.keys(issueData).length;
expect(wrapper.find('tbody tr')).toHaveLength(expectedNumberOfRows);
});
});

Просмотреть файл

@ -1,16 +0,0 @@
import React from 'react';
import { Helmet } from 'react-helmet';
const NotFound = () => (
<div>
<Helmet>
<title>404 Not Found | Add-ons PM</title>
</Helmet>
<main className="container">
<br />
<h1>Page Not Found</h1>
<p>Sorry we can't find the page you were looking for</p>
</main>
</div>
);
export default NotFound;

Просмотреть файл

@ -1,8 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import NotFound from './NotFound';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<NotFound />, div);
});

Просмотреть файл

@ -1,490 +0,0 @@
import React, { Component } from 'react';
import { Helmet } from 'react-helmet';
import { sanitize } from './utils';
import {
Button,
Card,
CardDeck,
Container,
Nav,
Navbar,
NavDropdown,
Row,
ProgressBar,
} from 'react-bootstrap';
import { LinkContainer } from 'react-router-bootstrap';
import TimeAgo from 'react-timeago';
import Octicon, { Clock, Project } from '@githubprimer/octicons-react';
import Client from './Client';
import Engineer from './components/Engineer';
class Projects extends Component {
state = {
projects: {
data: null,
},
team: {
data: null,
},
};
async getProjects(year, quarter) {
return await Client.getProjects(year, quarter);
}
async getTeam() {
return await Client.getTeam();
}
async componentDidUpdate(prevProps, prevState) {
await this.updateComponentData(prevProps, prevState);
}
async componentDidMount() {
await this.updateComponentData();
}
async updateComponentData(prevProps, prevState) {
const { match } = this.props;
let { year, quarter } = match.params;
// Check if we need to recalculate anything.
if (prevProps && prevProps.match) {
const prevMatch = prevProps.match;
if (
prevMatch.params.year === year &&
prevMatch.params.quarter === quarter
) {
console.log('No changes to project props, skipping update');
return;
}
}
// Scroll to top when new data loads.
window.scrollTo(0, 0);
if (year === 'latest') {
// Work out the current year/milestone.
const {
year: currentYear,
quarter: currentQuarter,
} = this.getCurrentQuarter();
year = currentYear;
quarter = currentQuarter;
// Update the url.
this.props.history.push(`/projects/${year}/${quarter}/`);
}
// Fetch all data in parallel.
const [projectData, teamData] = await Promise.all([
this.getProjects(year, quarter),
this.getTeam(),
]);
this.setState({
projects: this.buildMetaData(projectData),
team: teamData,
});
}
getCurrentQuarter() {
const today = new Date();
const year = today.getFullYear();
const quarter = `Q${Math.floor((today.getMonth() + 3) / 3)}`;
return {
year,
quarter,
};
}
getPrevQuarter() {
const { match } = this.props;
let { quarter, year } = match.params;
if (!quarter || !year) {
return {};
}
const numericQuarter = quarter.substr(1);
let newQuarter = parseInt(numericQuarter, 10);
let newYear = parseInt(year, 10);
if (newQuarter > 1) {
newQuarter = newQuarter - 1;
} else if (newQuarter === 1) {
newQuarter = 4;
newYear = newYear - 1;
}
return {
year: newYear,
quarter: `Q${newQuarter}`,
};
}
getNextQuarter() {
const { match } = this.props;
let { quarter, year } = match.params;
if (!quarter || !year) {
return {};
}
const numericQuarter = quarter.substr(1);
let newYear = parseInt(year, 10);
let newQuarter = parseInt(numericQuarter, 10);
if (newQuarter < 4) {
newQuarter = newQuarter + 1;
} else if (newQuarter === 4) {
newQuarter = 1;
newYear = newYear + 1;
}
return {
year: newYear,
quarter: `Q${newQuarter}`,
};
}
projectSort(a, b) {
const goalTypeA = a.meta.goalType ? a.meta.goalType : 'unclassified';
const goalTypeB = b.meta.goalType ? b.meta.goalType : 'unclassified';
return goalTypeA < goalTypeB ? -1 : goalTypeA > goalTypeB ? 1 : 0;
}
parseProjectMeta(HTML) {
const parser = new DOMParser();
const doc = parser.parseFromString(HTML, 'text/html');
const engineers = doc
.evaluate(
"//details//dl/dt[contains(., 'Engineering')]/following-sibling::dd[1]",
doc,
null,
2,
null,
)
.stringValue.replace(/ ?@/g, '')
.split(',');
const goalType = doc
.evaluate(
"//details//dl/dt[contains(., 'Goal Type')]/following-sibling::dd[1]",
doc,
null,
2,
null,
)
.stringValue.toLowerCase();
const size = doc.evaluate(
"//details//dl/dt[contains(., 'Size')]/following-sibling::dd[1]",
doc,
null,
2,
null,
).stringValue;
const details = doc.querySelector('details');
if (details) {
// Remove the meta data HTML from the doc.
// details.parentNode.removeChild(details);
}
return [{ engineers, goalType, size }, doc.documentElement.outerHTML];
}
// This function pre-computes counts and extracts metadata prior to the data being
// added to the state. This way we're not running the same code over and over again
// during the render.
buildMetaData(projectData) {
const newProjectData = { ...projectData };
const augmentedProjects = newProjectData.data.organization.projects.nodes.map(
(project, idx) => {
const [meta, updatedHTML] = this.parseProjectMeta(project.bodyHTML);
project.bodyHTML = updatedHTML;
const todoColumn = project.columns.edges.find((column) => {
return column.node.name === 'To do';
});
const inProgressColumn = project.columns.edges.find((column) => {
return column.node.name === 'In progress';
});
const doneColumn = project.columns.edges.find((column) => {
return column.node.name === 'Done';
});
const todoCount =
(todoColumn && parseInt(todoColumn.node.cards.totalCount, 10)) || 0;
const inProgressCount =
(inProgressColumn &&
parseInt(inProgressColumn.node.cards.totalCount, 10)) ||
0;
const doneCount =
(doneColumn && parseInt(doneColumn.node.cards.totalCount, 10)) || 0;
const totalCount = todoCount + inProgressCount + doneCount;
// console.log(project.name, { todoCount, inProgressCount, doneCount, totalCount });
const donePerc = totalCount ? (100 / totalCount) * doneCount : 0;
const inProgressPerc = totalCount
? (100 / totalCount) * inProgressCount
: 0;
project.meta = {
...meta,
todoCount,
inProgressCount,
doneCount,
donePerc,
inProgressPerc,
};
return project;
},
);
newProjectData.data.organization.projects.nodes = augmentedProjects;
return newProjectData;
}
render() {
const { match } = this.props;
const { year, quarter, projectType, engineer } = match.params;
const projectsData = this.state.projects;
const teamData = this.state.team;
let projects = null;
let currentProjectType = null;
if (projectsData.data) {
projectsData.data.organization.projects.nodes.sort(this.projectSort);
projects = projectsData.data.organization.projects.nodes.map(
(project, idx) => {
if (projectType && projectType !== null) {
if (!RegExp(projectType, 'i').test(project.meta.goalType)) {
return null;
}
}
let teamMembers = [];
if (teamData.data) {
for (const engineer of project.meta.engineers) {
const teamMembersToSearch = [
...teamData.data.organization.team.members.nodes,
...teamData.data.organization.outreachy.members.nodes,
];
const foundMember = teamMembersToSearch.find((item) => {
return item.login.toLowerCase() === engineer.toLowerCase();
});
if (foundMember) {
teamMembers.push(foundMember);
}
}
}
if (
engineer &&
project.meta.engineers &&
!project.meta.engineers.includes(engineer)
) {
return null;
}
const projectNode = (
<div className="card-wrapper" key={idx}>
{!projectType && currentProjectType === null ? (
<h2 className="project-type">Primary</h2>
) : null}
{!projectType &&
currentProjectType === 'primary' &&
project.meta.goalType === 'secondary' ? (
<h2 className="project-type">Secondary</h2>
) : null}
<Card
bg={project.meta.goalType === 'primary' ? 'muted' : 'light'}
>
<Card.Header as="h2">
<span>
<Octicon icon={Project} verticalAlign="middle" />
{project.name}
</span>
<div>
{teamMembers.map((member) => {
return (
<Engineer
key={member.login + project.name}
member={member}
project={project}
year={year}
quarter={quarter}
/>
);
})}
</div>
</Card.Header>
<ProgressBar>
<ProgressBar
variant="success"
now={project.meta.donePerc}
key={1}
/>
<ProgressBar
variant="warning"
now={project.meta.inProgressPerc}
key={2}
/>
</ProgressBar>
<Card.Body
dangerouslySetInnerHTML={{
__html: sanitize(project.bodyHTML),
}}
/>
<Card.Footer bg="light">
<span className="updated float-left">
<Octicon icon={Clock} /> Updated{' '}
<TimeAgo date={project.updatedAt} />
</span>
<Button
href={project.url}
size="sm"
className="float-right"
variant="outline-primary"
target="_blank"
rel="noreferrer noopener"
>
View Project
</Button>
</Card.Footer>
</Card>
<br />
</div>
);
currentProjectType = project.meta.goalType;
return projectNode;
},
);
}
// Filter null elements. This avoids a case where we have
// a projects list that looks like [null, null].
if (projects && projects.length) {
projects = projects.filter((el) => {
return el !== null;
});
}
const {
year: currentYear,
quarter: currentQuarter,
} = this.getCurrentQuarter();
const { year: prevYear, quarter: prevQuarter } = this.getPrevQuarter();
const { year: nextYear, quarter: nextQuarter } = this.getNextQuarter();
return (
<div>
<Helmet>
<title>Projects</title>
</Helmet>
<Navbar
variant="muted"
bg="light"
className="shadow-sm d-flex justify-content-between"
sticky="top"
>
<Nav variant="pills">
<Nav.Item>
<LinkContainer
to={`/projects/${prevYear}/${prevQuarter}/`}
active={false}
exact
>
<Nav.Link eventKey="prev" className="previous">
Previous
</Nav.Link>
</LinkContainer>
</Nav.Item>
<Nav.Item>
<LinkContainer
to={`/projects/${nextYear}/${nextQuarter}/`}
active={false}
exact
>
<Nav.Link eventKey="next" className="next">
Next
</Nav.Link>
</LinkContainer>
</Nav.Item>
</Nav>
<Nav variant="pills">
<Nav.Item>
<LinkContainer
to={`/projects/${currentYear}/${currentQuarter}/`}
exact
>
<Nav.Link
eventKey="current"
active={
this.props.location.pathname.indexOf(
`/projects/${currentYear}/${currentQuarter}/`,
) === 0
}
>
Current Quarter
</Nav.Link>
</LinkContainer>
</Nav.Item>
<NavDropdown className="filters" title="Filters" alignRight>
<LinkContainer to={`/projects/${year}/${quarter}/`} exact>
<NavDropdown.Item
eventKey="all"
href={`/projects/${year}/${quarter}/`}
>
All
</NavDropdown.Item>
</LinkContainer>
<LinkContainer to={`/projects/${year}/${quarter}/primary/`} exact>
<NavDropdown.Item
eventKey="primary"
href={`/projects/${year}/${quarter}/primary/`}
>
Primary
</NavDropdown.Item>
</LinkContainer>
<LinkContainer
to={`/projects/${year}/${quarter}/secondary/`}
exact
>
<NavDropdown.Item
eventKey="secondary"
href={`/projects/${year}/${quarter}/secondary/`}
>
Secondary
</NavDropdown.Item>
</LinkContainer>
</NavDropdown>
</Nav>
</Navbar>
<Container fluid>
<Row>
<Container as="main" bg="light">
<h1>
Projects for {quarter} {year}{' '}
{projectType
? `(${projectType})`
: engineer
? `(${engineer})`
: '(All)'}
</h1>
{projectsData.data === null ? <p>Loading...</p> : null}
{projects && projects.length ? (
<CardDeck>{projects}</CardDeck>
) : projects && projects.length === 0 ? (
<p>There are no Projects available for this quarter yet</p>
) : null}
</Container>
</Row>
</Container>
</div>
);
}
}
export default Projects;

Просмотреть файл

@ -1,165 +0,0 @@
/* global testData */
import React from 'react';
import Projects from './Projects';
import { mount } from 'enzyme';
import fetchMock from 'fetch-mock';
import { MemoryRouter } from 'react-router-dom';
const fakeLocation = {
pathname: '/2018/Q3/',
};
const fakeMatch = {
params: {
year: '2018',
quarter: 'Q3',
},
};
describe('Projects Page', () => {
beforeEach(() => {
// Mock API data
fetchMock.mock(/\/api\/team\//, testData.team);
fetchMock.mock(/\/api\/projects\//, testData.projects);
});
afterEach(() => {
fetchMock.restore();
});
it('should render some projects', async () => {
const wrapper = mount(
<MemoryRouter>
<Projects match={fakeMatch} location={fakeLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Projects').instance();
await instance.componentDidMount();
wrapper.update();
const projectData = instance.state.projects;
const expectedNumberOfProjects =
projectData.data.organization.projects.nodes.length;
expect(wrapper.find('.card-wrapper')).toHaveLength(
expectedNumberOfProjects,
);
});
it('should filter projects by type', async () => {
const filteredMatch = { ...fakeMatch };
filteredMatch.params = { ...fakeMatch.params, projectType: 'primary' };
const filteredLocation = { pathname: '/2018/Q3/primary/' };
const wrapper = mount(
<MemoryRouter>
<Projects match={filteredMatch} location={filteredLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Projects').instance();
await instance.componentDidMount();
wrapper.update();
expect(wrapper.find('.card-wrapper')).toHaveLength(1);
});
it('should filter projects by engineer', async () => {
const filteredMatch = { ...fakeMatch };
filteredMatch.params = { ...fakeMatch.params, engineer: 'testuser' };
const filteredLocation = { pathname: '/2018/Q3/testuser/' };
const wrapper = mount(
<MemoryRouter>
<Projects match={filteredMatch} location={filteredLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Projects').instance();
await instance.componentDidMount();
wrapper.update();
expect(wrapper.find('.card-wrapper')).toHaveLength(1);
});
describe('getNextQuarter()', () => {
it('should provide the right project link data for 2019/Q3', async () => {
const filteredMatch = { ...fakeMatch };
filteredMatch.params = {
...fakeMatch.params,
year: '2019',
quarter: 'Q3',
};
const filteredLocation = { pathname: '/2019/Q3/' };
const wrapper = mount(
<MemoryRouter>
<Projects match={filteredMatch} location={filteredLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Projects').instance();
const { year, quarter } = instance.getNextQuarter();
expect(year).toBe(2019);
expect(quarter).toBe('Q4');
});
it('should provide the right project link data for 2019/Q4', async () => {
const filteredMatch = { ...fakeMatch };
filteredMatch.params = {
...fakeMatch.params,
year: '2019',
quarter: 'Q4',
};
const filteredLocation = { pathname: '/2019/Q4/' };
const wrapper = mount(
<MemoryRouter>
<Projects match={filteredMatch} location={filteredLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Projects').instance();
const { year, quarter } = instance.getNextQuarter();
expect(year).toBe(2020);
expect(quarter).toBe('Q1');
});
});
describe('getPrevQuarter()', () => {
it('should provide the right project link data for 2019/Q3', async () => {
const filteredMatch = { ...fakeMatch };
filteredMatch.params = {
...fakeMatch.params,
year: '2019',
quarter: 'Q3',
};
const filteredLocation = { pathname: '/2019/Q3/' };
const wrapper = mount(
<MemoryRouter>
<Projects match={filteredMatch} location={filteredLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Projects').instance();
const { year, quarter } = instance.getPrevQuarter();
expect(year).toBe(2019);
expect(quarter).toBe('Q2');
});
it('should provide the right project link data for 2019/Q1', async () => {
const filteredMatch = { ...fakeMatch };
filteredMatch.params = {
...fakeMatch.params,
year: '2019',
quarter: 'Q1',
};
const filteredLocation = { pathname: '/2019/Q1/' };
const wrapper = mount(
<MemoryRouter>
<Projects match={filteredMatch} location={filteredLocation} />
</MemoryRouter>,
{ disableLifecycleMethods: true },
);
const instance = wrapper.find('Projects').instance();
const { year, quarter } = instance.getPrevQuarter();
expect(year).toBe(2018);
expect(quarter).toBe('Q4');
});
});
});

Просмотреть файл

@ -1,30 +0,0 @@
.outer {
align-items: center;
display: flex;
flex-grow: 1;
justify-content: center;
overflow: visible;
svg {
height: 100%;
}
circle {
fill: #3db8a4;
}
text {
fill: #fff;
font-family: sans-serif;
font-size: 3.5rem;
text-shadow: -1px -1px rgba(0, 0, 0, 0.1);
}
}
.warning circle {
fill: rgb(222, 80, 41);
}
.total circle {
fill: #45a1ff;
}

Просмотреть файл

@ -1,19 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Image } from 'react-bootstrap';
export default function Engineer(props) {
const { member, year, quarter } = props;
return (
<Link to={`/projects/${year}/${quarter}/${member.login.toLowerCase()}/`}>
<Image
src={member.avatarUrl}
title={member.login}
alt={member.login}
roundedCircle
className="float-right eng-image"
height="35"
/>
</Link>
);
}

Просмотреть файл

@ -1,20 +0,0 @@
/* global testData */
import React from 'react';
import { shallow } from 'enzyme';
import Engineer from './Engineer';
describe('Projects Page', () => {
it('should lowercase user slug in links', async () => {
const fakeProps = {
member: testData.team.data.organization.team.members.nodes[1],
quarter: 'Q3',
year: '2018',
};
const eng = shallow(<Engineer {...fakeProps} />);
expect(eng.find('Link').prop('to')).toBe(
'/projects/2018/Q3/team-testuser-2/',
);
});
});

Двоичные данные
src/client/images/felt.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 129 KiB

Просмотреть файл

@ -1,34 +0,0 @@
body {
font-family: sans-serif;
margin: 0;
padding: 0;
}
.home {
&.container {
max-width: 1024px;
}
h3 {
clear: both;
margin-top: 1.5rem;
}
.btn {
float: right;
min-width: 10em;
}
.octicon {
box-sizing: content-box;
padding: 0 20px 20px 0;
}
.card {
margin-bottom: 1.5rem;
}
}
.filters {
margin-left: 10px;
}

Просмотреть файл

@ -1,88 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* Photon Colors SCSS Variables v3.2.0 */
$magenta-50: #ff1ad9;
$magenta-60: #ed00b5;
$magenta-70: #b5007f;
$magenta-80: #7d004f;
$magenta-90: #440027;
$purple-30: #c069ff;
$purple-40: #ad3bff;
$purple-50: #9400ff;
$purple-60: #8000d7;
$purple-70: #6200a4;
$purple-80: #440071;
$purple-90: #25003e;
$blue-40: #45a1ff;
$blue-50: #0a84ff;
$blue-50-a30: rgba(10, 132, 255, 0.3);
$blue-60: #0060df;
$blue-70: #003eaa;
$blue-80: #002275;
$blue-90: #000f40;
$teal-50: #00feff;
$teal-60: #00c8d7;
$teal-70: #008ea4;
$teal-80: #005a71;
$teal-90: #002d3e;
$green-50: #30e60b;
$green-60: #12bc00;
$green-70: #058b00;
$green-80: #006504;
$green-90: #003706;
$yellow-50: #ffe900;
$yellow-60: #d7b600;
$yellow-70: #a47f00;
$yellow-80: #715100;
$yellow-90: #3e2800;
$red-50: #ff0039;
$red-60: #d70022;
$red-70: #a4000f;
$red-80: #5a0002;
$red-90: #3e0200;
$orange-50: #ff9400;
$orange-60: #d76e00;
$orange-70: #a44900;
$orange-80: #712b00;
$orange-90: #3e1300;
$grey-10: #f9f9fa;
$grey-10-a10: rgba(249, 249, 250, 0.1);
$grey-10-a20: rgba(249, 249, 250, 0.2);
$grey-10-a40: rgba(249, 249, 250, 0.4);
$grey-10-a60: rgba(249, 249, 250, 0.6);
$grey-10-a80: rgba(249, 249, 250, 0.8);
$grey-20: #ededf0;
$grey-30: #d7d7db;
$grey-40: #b1b1b3;
$grey-50: #737373;
$grey-60: #4a4a4f;
$grey-70: #38383d;
$grey-80: #2a2a2e;
$grey-90: #0c0c0d;
$grey-90-a05: rgba(12, 12, 13, 0.05);
$grey-90-a10: rgba(12, 12, 13, 0.1);
$grey-90-a20: rgba(12, 12, 13, 0.2);
$grey-90-a30: rgba(12, 12, 13, 0.3);
$grey-90-a40: rgba(12, 12, 13, 0.4);
$grey-90-a50: rgba(12, 12, 13, 0.5);
$grey-90-a60: rgba(12, 12, 13, 0.6);
$grey-90-a70: rgba(12, 12, 13, 0.7);
$grey-90-a80: rgba(12, 12, 13, 0.8);
$grey-90-a90: rgba(12, 12, 13, 0.9);
$ink-70: #363959;
$ink-80: #202340;
$ink-90: #0f1126;
$white-100: #fff;

Просмотреть файл

@ -1,143 +0,0 @@
import { oneLineTrim } from 'common-tags';
import DOMPurify from 'dompurify';
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if ('target' in node) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
// Polyfill padStart if it's not available.
if (!String.prototype.padStart) {
console.log('Polyfilling padStart');
/* eslint-disable-next-line no-extend-native */
String.prototype.padStart = function (length, repeated) {
return repeated.repeat(length).substring(0, length - this.length) + this;
};
}
export function hasLabel(issueLabels, labelOrLabelList) {
const labels = issueLabels || [];
if (Array.isArray(labelOrLabelList)) {
return labels.some((item) => labelOrLabelList.includes(item.name));
}
return !!labels.find((label) => label.name === labelOrLabelList);
}
export function hasLabelContainingString(issueLabels, string) {
const labels = issueLabels || [];
const rx = new RegExp(string);
return !!labels.find((label) => rx.test(label.name));
}
export function dateSort(key) {
return (a, b) => {
return new Date(a[key]) - new Date(b[key]);
};
}
export function numericSort(key) {
return (a, b) => {
return a[key] - b[key];
};
}
export function alphaSort(key) {
return (a, b) => {
var strA = a[key].toUpperCase();
var strB = b[key].toUpperCase();
if (strA < strB) {
return -1;
}
if (strA > strB) {
return 1;
}
// names must be equal
return 0;
};
}
// This function should return the next nearest release
// date including if the release date is today.
// dayOfWeek: Sunday is 0, Monday is 1 etc...
export function getNextMilestone({
dayOfWeek = 4,
startDate = new Date(),
} = {}) {
if (startDate.getDay() === dayOfWeek) {
return startDate;
}
const resultDate = new Date(startDate.getTime());
resultDate.setDate(
startDate.getDate() + ((7 + dayOfWeek - startDate.getDay() - 1) % 7) + 1,
);
return resultDate;
}
// Formats a date object into a milestone format YYYY.MM.DD
// Handles zero filling so 2019.1.1 will be 2019.01.01
export function formatDateToMilestone(date) {
return oneLineTrim`${date.getFullYear()}.
${(date.getMonth() + 1).toString().padStart(2, '0')}.
${date.getDate().toString().padStart(2, '0')}`;
}
export function getMilestonePagination({
dayOfWeek = 4,
startDate = new Date(),
} = {}) {
// The nearest release milestone to the starting point.
let nextMilestone = getNextMilestone({ dayOfWeek, startDate });
const prev = new Date(
nextMilestone.getFullYear(),
nextMilestone.getMonth(),
nextMilestone.getDate() - 7,
);
// Set next Milestone to 7 days time if we're starting on current milestone date already.
if (
formatDateToMilestone(startDate) === formatDateToMilestone(nextMilestone)
) {
nextMilestone = new Date(
nextMilestone.getFullYear(),
nextMilestone.getMonth(),
nextMilestone.getDate() + 7,
);
}
// The current milestone closest to today.
const currentMilestone = getNextMilestone(dayOfWeek);
return {
// The milestone before the startDate.
prevFromStart: formatDateToMilestone(prev),
// The startDate milestone (might not be a typical release day).
start: formatDateToMilestone(startDate),
// The milestone after the startDate.
nextFromStart: formatDateToMilestone(nextMilestone),
// The current closest milestone to today.
current: formatDateToMilestone(currentMilestone),
};
}
export function hexToRgb(hex) {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: {};
}
export function colourIsLight(hex) {
const { r, g, b } = hexToRgb(hex);
// Counting the perceptive luminance
// human eye favors green color...
var a = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return a < 0.5;
}
export const sanitize = DOMPurify.sanitize;

Просмотреть файл

@ -1,186 +0,0 @@
import {
alphaSort,
colourIsLight,
dateSort,
formatDateToMilestone,
getNextMilestone,
getMilestonePagination,
hasLabel,
hasLabelContainingString,
hexToRgb,
numericSort,
sanitize,
} from './utils';
describe('Utils', () => {
it('sanitize() sanitizes bad markup', () => {
const sanitized = sanitize(
'<a href="javascript: alert(document.cookie)">foo</a>',
);
expect(sanitized).not.toEqual(expect.stringMatching('javascript'));
});
it('sanitize() adds target="_blank" and rel="noopener noreferrer" to links', () => {
const sanitized = sanitize('<a href="#whatevs">foo</a>');
expect(sanitized).toEqual(
expect.stringMatching('rel="noopener noreferrer"'),
);
expect(sanitized).toEqual(expect.stringMatching('target="_blank"'));
});
it('hexToRgb() converts hex to rgb', () => {
const { r, g, b } = hexToRgb('#ffffff');
expect(r).toEqual(255);
expect(g).toEqual(255);
expect(b).toEqual(255);
});
it('colourIsLight() returns useful values', () => {
expect(colourIsLight('#ffffff')).toEqual(true);
expect(colourIsLight('#000000')).not.toEqual(true);
});
describe('hasLabel()', () => {
const fakeIssueLabels = [
{ name: 'foo' },
{ name: 'fooBar' },
{ name: 'something' },
];
it('returns true for exact match', () => {
expect(hasLabel(fakeIssueLabels, 'foo')).toEqual(true);
});
it('returns false for partial match', () => {
expect(hasLabel(fakeIssueLabels, 'thing')).toEqual(false);
});
it('returns true for one of list input', () => {
expect(hasLabel(fakeIssueLabels, ['foo', 'bar'])).toEqual(true);
});
it('returns false for partial match of list input', () => {
expect(hasLabel(fakeIssueLabels, ['thing', 'bar'])).toEqual(false);
});
});
describe('hasLabelContainingString()', () => {
const fakeIssueLabels = [
{ name: 'foo' },
{ name: 'fooBar' },
{ name: 'bar' },
{ name: 'baz' },
{ name: 'something' },
];
it('returns true for exact match', () => {
expect(hasLabelContainingString(fakeIssueLabels, 'foo')).toEqual(true);
});
it('returns true for partial match', () => {
expect(hasLabelContainingString(fakeIssueLabels, 'thing')).toEqual(true);
});
});
describe('dateSort()', () => {
const data = [
{ date: '2019-03-25T17:27:07Z' },
{ date: '2019-03-20T15:51:59Z' },
];
it('sorts dates', () => {
const result = [].concat(data).sort(dateSort('date'));
expect(result[0]).toEqual(data[1]);
});
});
describe('numericSort()', () => {
const data = [{ num: 2 }, { num: 1 }];
it('sorts numbers', () => {
const result = [].concat(data).sort(numericSort('num'));
expect(result[0]).toEqual(data[1]);
});
});
describe('alphaSort()', () => {
const data = [
{ letters: 'bbcc' },
{ letters: 'aabbcc' },
{ letters: 'cccddd' },
{ letters: 'bbcc' },
];
it('sorts letters', () => {
const result = [].concat(data).sort(alphaSort('letters'));
expect(result[0]).toEqual(data[1]);
});
});
describe('getNextMilestone()', () => {
it('gets the next nearest Thursday', () => {
// Note months are zero indexed.
const startDate = new Date('2019', '3', '9');
const nextMilestone = getNextMilestone({ startDate, dayOfWeek: 4 });
expect(formatDateToMilestone(nextMilestone)).toEqual('2019.04.11');
});
it('gets the next nearest Thursday when start date is Thursday', () => {
// Note months are zero indexed.
const startDate = new Date('2019', '3', '11');
const nextMilestone = getNextMilestone({ startDate, dayOfWeek: 4 });
expect(formatDateToMilestone(nextMilestone)).toEqual('2019.04.11');
});
it('gets the next nearest Monday', () => {
// Note months are zero indexed.
const startDate = new Date('2019', '3', '11');
const nextMilestone = getNextMilestone({ startDate, dayOfWeek: 1 });
expect(formatDateToMilestone(nextMilestone)).toEqual('2019.04.15');
});
});
describe('formatDateToMilestone()', () => {
it('zero fills months and days', () => {
const startDate = new Date('2019', '3', '9');
expect(formatDateToMilestone(startDate)).toEqual('2019.04.09');
});
it(`doesn't zero fill months and days when not needed`, () => {
const startDate = new Date('2019', '11', '15');
expect(formatDateToMilestone(startDate)).toEqual('2019.12.15');
});
});
describe('getMilestonePagination()', () => {
it('gets pagination based on a specific start date', () => {
// Note months are zero indexed.
const startDate = new Date('2019', '3', '9');
const milestonePagination = getMilestonePagination({
startDate,
dayOfWeek: 4,
});
expect(milestonePagination.prevFromStart).toEqual('2019.04.04');
expect(milestonePagination.start).toEqual('2019.04.09');
expect(milestonePagination.nextFromStart).toEqual('2019.04.11');
expect(milestonePagination.current).toEqual(
formatDateToMilestone(getNextMilestone()),
);
});
it('gets pagination based on a specific start date that matches the dayOfWeek', () => {
// Note months are zero indexed.
const startDate = new Date('2019', '3', '11');
const milestonePagination = getMilestonePagination({
startDate,
dayOfWeek: 4,
});
expect(milestonePagination.prevFromStart).toEqual('2019.04.04');
expect(milestonePagination.start).toEqual('2019.04.11');
expect(milestonePagination.nextFromStart).toEqual('2019.04.18');
expect(milestonePagination.current).toEqual(
formatDateToMilestone(getNextMilestone()),
);
});
});
});

Просмотреть файл

@ -1,44 +0,0 @@
import * as constants from './const';
describe('const.validYearRX', () => {
it('should match valid content', () => {
expect('2019').toMatch(constants.validYearRX);
expect('2020').toMatch(constants.validYearRX);
});
it('should not match invalid content', () => {
expect('201113').not.toMatch(constants.validYearRX);
expect('1977').not.toMatch(constants.validYearRX);
expect('2016').not.toMatch(constants.validYearRX);
});
});
describe('const.validQuarterRX', () => {
it('should match valid content', () => {
expect('Q1').toMatch(constants.validQuarterRX);
expect('Q2').toMatch(constants.validQuarterRX);
expect('Q3').toMatch(constants.validQuarterRX);
expect('Q4').toMatch(constants.validQuarterRX);
});
it('should not match invalid content', () => {
expect('Q5').not.toMatch(constants.validQuarterRX);
expect('whatever').not.toMatch(constants.validQuarterRX);
});
});
describe('const.validMilestoneRX', () => {
it('should match valid content', () => {
expect('2019.04.04').toMatch(constants.validMilestoneRX);
expect('2018.03.12').toMatch(constants.validMilestoneRX);
expect('2019.12.31').toMatch(constants.validMilestoneRX);
expect('2019.01.01').toMatch(constants.validMilestoneRX);
});
it('should not match invalid content', () => {
expect('2016.11.11').not.toMatch(constants.validMilestoneRX);
expect('2019.11.32').not.toMatch(constants.validMilestoneRX);
expect('2019.13.32').not.toMatch(constants.validMilestoneRX);
expect('whatever').not.toMatch(constants.validMilestoneRX);
});
});

Просмотреть файл

@ -1,3 +0,0 @@
export const bugzillaIssueCounts = {
bug_count: 20,
};

Просмотреть файл

@ -1,28 +0,0 @@
export const bugzillaNeedsInfoLocal = {
mixedpuppy: {
count: 21,
url:
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1329853,1355239,1373297,1411209,1595610,1605515,1611878,1620100,1627939,1628642,1629734,1633189,1635344,1637059,1645341,1646016,1653841,1654355,1655040,1655353,1656732',
},
rwu: {
count: 13,
url:
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1402612,1426789,1435473,1490052,1561604,1589896,1595513,1595610,1600556,1602005,1602335,1643558,1650956',
},
rpl: {
count: 6,
url:
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1433543,1490857,1626252,1637616,1651434,1659190',
},
zombie: {
count: 19,
url:
'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1284938,1398672,1437051,1581859,1594921,1604538,1605518,1612800,1617962,1619140,1625271,1635062,1635598,1635637,1655528,1655898,165606,1656671,1657384',
},
muffinresearch: { count: 0 },
UX: {
count: 2,
url: 'https://bugzilla.mozilla.org/buglist.cgi?bug_id=1531976,1647056',
},
JorgeV: { count: 0 },
};

Просмотреть файл

@ -1,13 +0,0 @@
// This file is ignored from coverage
// since it's mostly boilerplate. If something
// is added here that needs testing please update the
// test script in package.json to include it.
import React from 'react';
import ReactDOM from 'react-dom';
import App from './client/App';
import 'bootstrap/dist/css/bootstrap.css';
import './client/index.scss';
ReactDOM.render(<App />, document.getElementById('root'));

Просмотреть файл

@ -1,163 +0,0 @@
// Magically require any env variables defined in a local .env file.
require('dotenv').config();
// Polyfill fetch.
const serverSWR = require('./serverSWR');
const queryString = require('query-string');
const baseAPIURL = 'https://bugzilla.mozilla.org/rest/bug';
const baseWEBURL = 'https://bugzilla.mozilla.org/buglist.cgi';
const needsInfoURL = `${baseAPIURL}?include_fields=id&f1=flagtypes.name&f2=requestees.login_name&o1=casesubstring&o2=equals&v1=needinfo%3F&v2=`;
const webExtOnlyQualifier =
'&component=Add-ons Manager&component=Android&component=Compatibility&component=Developer Outreach&component=Developer Tools&component=Experiments&component=Frontend&component=General&component=Request Handling&component=Storage&component=Themes&component=Untriaged&product=Toolkit&product=WebExtensions';
const priorities = ['--', 'P1', 'P2', 'P3', 'P4', 'P5'];
const severities = ['normal', '--', 'N/A', 'S1', 'S2', 'S3', 'S4'];
const products = ['Toolkit', 'WebExtensions', 'Firefox'];
const defaultParams = {
count_only: true,
resolution: '---',
bug_status: ['UNCONFIRMED', 'NEW', 'ASSIGNED', 'REOPENED'],
limit: 0,
};
function fetchIssueCount({ priority, product, bug_severity } = {}) {
const params = { ...defaultParams, product, priority, bug_severity };
if (params.bug_priority && params.bug_severity) {
throw new Error('Query only severity or priority independently');
}
if (bug_severity) {
delete params.priority;
}
if (priority) {
delete params.bug_severity;
}
// console.log(JSON.stringify(params));
if (product === 'Toolkit') {
params.component = 'Add-ons Manager';
} else if (product === 'Firefox') {
params.component = 'Extension Compatibility';
}
const apiURL = `${baseAPIURL}?${queryString.stringify(params)}`;
const webParams = { ...params };
delete webParams.count_only;
const webURL = `${baseWEBURL}?${queryString.stringify(webParams)}`;
return serverSWR(apiURL, async () => {
const res = await fetch(apiURL, {
headers: { 'Content-Type': 'application/json' },
});
const json = await res.json();
return { count: json.bug_count, url: webURL };
});
}
function getBugzillaIssueCounts() {
const requests = [];
const combinedData = {};
for (const product of products) {
combinedData[product] = {};
for (const priority of priorities) {
requests.push(
fetchIssueCount({
product,
priority,
bug_severity: null,
}).then((result) => {
let priorityLabel;
switch (priority) {
case '--':
priorityLabel = 'default';
break;
default:
priorityLabel = priority.toLowerCase();
}
combinedData[product][`priority-${priorityLabel}`] = result;
}),
);
}
for (const bug_severity of severities) {
requests.push(
fetchIssueCount({
product,
bug_severity,
priority: null,
}).then((result) => {
let severityLabel;
switch (bug_severity) {
case 'N/A':
severityLabel = 'not-applicable';
break;
case '--':
severityLabel = 'default';
break;
default:
severityLabel = bug_severity.toLowerCase();
}
combinedData[product][`severity-${severityLabel}`] = result;
}),
);
}
requests.push(
fetchIssueCount({
product,
bug_severity: null,
priority: null,
}).then((result) => {
combinedData[product][`total`] = result;
}),
);
}
return Promise.all(requests).then(() => {
return combinedData;
});
}
function fetchNeedInfo(email, nick) {
const encodedEmail = encodeURIComponent(email);
let apiURL = `${needsInfoURL}${encodedEmail}`;
if (nick === 'UX') {
// Limit UX need-infos to web-ext components and products.
apiURL = `${apiURL}${webExtOnlyQualifier}`;
}
return serverSWR(apiURL, async () => {
const result = await fetch(apiURL, {
headers: { 'Content-Type': 'application/json' },
});
return result.json();
});
}
function getBugzillaNeedInfos() {
const requests = [];
const combinedData = {};
const BZ_USERS = JSON.parse(process.env.BZ_USERS) || {};
for (const nick in BZ_USERS) {
combinedData[nick] = {};
requests.push(
fetchNeedInfo(BZ_USERS[nick], nick).then((result) => {
combinedData[nick].count = result.bugs.length;
if (result.bugs.length) {
combinedData[nick].url = `${baseWEBURL}?bug_id=${result.bugs
.map((item) => item.id)
.join(',')}`;
}
}),
);
}
return Promise.all(requests).then(() => {
return combinedData;
});
}
module.exports = {
getBugzillaIssueCounts,
getBugzillaNeedInfos,
};

Просмотреть файл

@ -1,116 +0,0 @@
// Magically require any env variables defined in a local .env file.
require('dotenv').config();
// Polyfill fetch.
require('isomorphic-fetch');
const ApolloClient = require('apollo-client').ApolloClient;
const createHttpLink = require('apollo-link-http').createHttpLink;
const InMemoryCache = require('apollo-cache-inmemory').InMemoryCache;
const IntrospectionFragmentMatcher = require('apollo-cache-inmemory')
.IntrospectionFragmentMatcher;
const getProjectsQuery = require('./queries/getProjects').projects;
const getTeamQuery = require('./queries/getTeam').team;
const getIssueCountQuery = require('./queries/getIssueCounts').issueCounts;
const getGoodFirstBugsQuery = require('./queries/getGoodFirstBugs')
.goodFirstBugs;
const getMaybeGoodFirstBugsQuery = require('./queries/getMaybeGoodFirstBugs')
.maybeGoodFirstBugs;
const getMilestoneIssuesQuery = require('./queries/getMilestoneIssues')
.milestoneIssues;
const getContribWelcomeQuery = require('./queries/getContribWelcome')
.contribWelcome;
const introspectionQueryResultData = require('./fragmentTypes.json');
function createClient() {
const headers = {};
if (process.env.NODE_ENV !== 'test') {
if (process.env.GH_TOKEN) {
headers.Authorization = `token ${process.env.GH_TOKEN}`;
} else {
throw new Error('No GH_TOKEN found');
}
}
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
// For fetches to work correctly we use a new client instance for
// each request to avoid stale data.
const client = new ApolloClient({
link: createHttpLink({
uri: 'https://api.github.com/graphql',
headers,
}),
cache: new InMemoryCache({ fragmentMatcher }),
});
return client;
}
async function getProjects(variables) {
const client = createClient();
const data = await client.query({
query: getProjectsQuery,
variables,
});
return data;
}
async function getMilestoneIssues(variables) {
const client = createClient();
const data = await client.query({
query: getMilestoneIssuesQuery,
variables,
});
return data;
}
async function getTeam() {
const client = createClient();
const data = await client.query({
query: getTeamQuery,
});
return data;
}
async function getGithubIssueCounts() {
const client = createClient();
const data = await client.query({
query: getIssueCountQuery,
});
return data;
}
async function getGoodFirstBugs() {
const client = createClient();
const data = await client.query({
query: getGoodFirstBugsQuery,
});
return data;
}
async function getMaybeGoodFirstBugs() {
const client = createClient();
const data = await client.query({
query: getMaybeGoodFirstBugsQuery,
});
return data;
}
async function getContribWelcome() {
const client = createClient();
const data = await client.query({
query: getContribWelcomeQuery,
});
return data;
}
module.exports = {
getProjects,
getTeam,
getGithubIssueCounts,
getGoodFirstBugs,
getMaybeGoodFirstBugs,
getMilestoneIssues,
getContribWelcome,
};

Просмотреть файл

@ -1,126 +0,0 @@
const fs = require('fs');
const express = require('express');
const app = express();
const ghapi = require('./ghapi');
const bzapi = require('./bzapi');
const constants = require('../const');
const getProjects = async (req, res, next) => {
const { year, quarter } = req.query;
if (!constants.validYearRX.test(year)) {
res.status(400).json({ error: 'Incorrect year format' });
} else if (!constants.validQuarterRX.test(quarter)) {
res.status(400).json({ error: 'Incorrect quarter format' });
} else {
const projects = await ghapi.getProjects({
projectSearch: `Add-ons ${quarter} ${year}`,
});
res.json(projects);
}
};
const getMilestoneIssues = async (req, res) => {
const { milestone } = req.query;
if (!constants.validMilestoneRX.test(milestone)) {
res.status(400).json({ error: 'Incorrect milestone format' });
} else {
const query = `repo:mozilla/addons
repo:mozilla/addons-server
repo:mozilla/addons-frontend
repo:mozilla/addons-linter
repo:mozilla/addons-code-manager
milestone:${milestone}
type:issues`;
const milestoneIssues = await ghapi.getMilestoneIssues({
query: query,
});
res.json(milestoneIssues);
}
};
const getTeam = async (req, res) => {
const team = await ghapi.getTeam();
res.json(team);
};
const getGithubIssueCounts = async (req, res) => {
const issueCounts = await ghapi.getGithubIssueCounts();
res.json(issueCounts);
};
const getBugzillaIssueCounts = async (req, res) => {
const issueCounts = await bzapi.getBugzillaIssueCounts();
res.json(issueCounts);
};
const getBugzillaNeedInfos = async (req, res) => {
const needInfos = await bzapi.getBugzillaNeedInfos();
res.json(needInfos);
};
const getGoodFirstBugs = async (req, res) => {
const goodFirstBugs = await ghapi.getGoodFirstBugs();
res.json(goodFirstBugs);
};
const getMaybeGoodFirstBugs = async (req, res) => {
const maybeGoodFirstBugs = await ghapi.getMaybeGoodFirstBugs();
res.json(maybeGoodFirstBugs);
};
const getContribWelcomeBugs = async (req, res) => {
const contribWelcomeBugs = await ghapi.getContribWelcome();
res.json(contribWelcomeBugs);
};
function handleErrors(processRequest) {
return async (req, res, next) => {
try {
await processRequest(req, res, next);
} catch (error) {
next(error);
}
};
}
app.get('/api/projects/', handleErrors(getProjects));
app.get('/api/team/', handleErrors(getTeam));
app.get('/api/github-issue-counts/', handleErrors(getGithubIssueCounts));
app.get('/api/bugzilla-issue-counts/', handleErrors(getBugzillaIssueCounts));
app.get('/api/bugzilla-need-infos/', handleErrors(getBugzillaNeedInfos));
app.get('/api/good-first-bugs/', handleErrors(getGoodFirstBugs));
app.get('/api/maybe-good-first-bugs/', handleErrors(getMaybeGoodFirstBugs));
app.get('/api/milestone-issues/', handleErrors(getMilestoneIssues));
app.get('/api/contrib-welcome/', handleErrors(getContribWelcomeBugs));
function startServer() {
let portOrSocket = process.env.PORT || 5000;
if (process.env.NODE_ENV === 'production') {
portOrSocket = '/tmp/nginx.socket';
}
app.listen(portOrSocket);
if (process.env.DYNO) {
console.log('This is on Heroku..!!');
fs.openSync('/tmp/app-initialized', 'w');
}
console.log(`Addons-PM Server listening on ${portOrSocket}`);
}
if (typeof module !== 'undefined' && !module.parent) {
startServer();
}
module.exports = {
getProjects,
getTeam,
getGithubIssueCounts,
getBugzillaIssueCounts,
getBugzillaNeedInfos,
getMilestoneIssues,
handleErrors,
startServer,
};

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше