Initial commit

This commit is contained in:
Yuri Sizov
2023-03-03 23:39:33 +01:00
commit ef466593c5
26 changed files with 3056 additions and 0 deletions

57
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Continuous integration
on:
push:
branches: [ master ]
schedule:
# Run every 15 minutes of every hour, starting at 5 minutes (5m, 20m, 35m, 50m).
# The slight offset is there to try and avoid the high load times.
- cron: '5/15 * * * *'
# Make sure jobs cannot overlap (e.g. one from push and one from schedule).
concurrency:
group: pages-ci
cancel-in-progress: true
jobs:
build:
name: Build and deploy to GitHub Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build the static content using npm
run: npm run build
- name: Fetch pull request data
run: npm run compose-db
env:
GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Archive production artifacts
uses: actions/upload-artifact@v3
with:
name: web-static
path: out
- name: Deploy to GitHub Pages 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: gh-pages
folder: out
# Configure the commit author.
git-config-name: 'Godot Organization'
git-config-email: '<>'
# Don't keep the history.
single-commit: true

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Project folders.
node_modules/
out/
logs/
# Development environments.
.idea/
.vscode/

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
# MIT License
Copyright © 2023-present Godot Engine contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# Godot PRs by File
This project is provided for Godot Engine contributors to quickly find open
PRs editing a specific file or folder. With the amount of work that goes into
Godot it becomes tricky to keep in mind every PR that touches every file, and
identify conflicts or duplicates. This project aims to help with that.
Live website: https://godotengine.github.io/godot-prs-by-file/
## Contributing
This project is written in JavaScript and is built using Node.JS. HTML and CSS are
used for the presentation. The end result of the build process is completely static
and can be server from any web server, no Node.JS required.
Front-end is designed in a reactive manner using industry standard Web Components
(powered by `lit-element`). This provides native browser support, and results in a
small overhead from the build process.
To build the project locally you need to have Node.JS installed (12.x and newer
should work just fine).
This project uses GitHub's GraphQL API. To fetch live data you need to generate
a [personal OAuth token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token).
You can supply your token to the scripts using the `GRAPHQL_TOKEN` environment
variable. Note, that if you don't have member access to the organization, you
may not be able to access all the information used when generating the database.
1. Clone or download the project.
2. From the project root run `npm install` or `yarn` to install dependencies.
3. Run `npm run build` or `yarn run build` to build the pages.
4. Run `npm run compose-db` or `yarn run compose-db` to fetch the data from GitHub.
5. Serve the `out/` folder with your method of choice (e.g. using Python 3:
`python -m http.server 8080 -d ./out`).
`rollup` is used for browser packing of scripts and copying of static assets. The
data fetching script is plain JavaScript with `node-fetch` used to polyfill
`fetch()`-like API.
## License
This project is provided under the [MIT License](LICENSE.md).

41
build/posthtml-include.js Normal file
View File

@@ -0,0 +1,41 @@
import fs from "fs";
import path from "path";
import parser from "posthtml-parser";
export default function(options) {
options = options || {};
options.root = options.root || './';
options.encoding = options.encoding || 'utf-8';
return function posthtmlInclude(tree) {
tree.match({ tag: 'include' }, function(node) {
if (!node.attrs.src) {
return {
tag: false,
content: null
};
}
const src = path.resolve(options.root, node.attrs.src);
const source = fs.readFileSync(src, options.encoding);
const subtree = parser(source);
subtree.match = tree.match;
const content = source.indexOf('include') !== -1? posthtmlInclude(subtree): subtree;
if (tree.messages) {
tree.messages.push({
type: "dependency",
file: src
});
}
return {
tag: false,
content: content
};
});
return tree;
};
};

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Nothing to see here</title>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,31 @@
import { promises as fs } from 'fs';
import posthtml from 'posthtml';
import include from './posthtml-include';
import { green } from 'colorette';
export default function(options = {}) {
return {
name: 'posthtml',
buildEnd: async () => {
if (!options.src || !options.dest) {
return;
}
const html = await fs.readFile(options.src, { encoding: 'utf-8' });
const plugins = [
include({
root: './src'
})
];
const result = await posthtml(plugins).process(html);
try {
await fs.unlink(options.dest);
} catch (exc) { }
await fs.writeFile(options.dest, result.html, { encoding: 'utf-8' });
console.log(green(`written html template ${options.dest}`))
}
};
}

456
compose-db.js Normal file
View File

@@ -0,0 +1,456 @@
const fs = require('fs').promises;
const fsConstants = require('fs').constants;
const path = require('path');
const fetch = require('node-fetch');
const PULLS_PER_PAGE = 100;
let page_count = 1;
let last_cursor = "";
const ExitCodes = {
"RequestFailure": 1,
"ParseFailure": 2,
};
const API_REST_PATH = `https://api.github.com/repos/godotengine/godot`;
const API_REPOSITORY_ID = `owner:"godotengine" name:"godot"`;
const API_RATE_LIMIT = `
rateLimit {
limit
cost
remaining
resetAt
}
`;
class DataFetcher {
async _logResponse(data, name) {
try {
try {
await fs.access("logs", fsConstants.R_OK | fsConstants.W_OK);
} catch (err) {
await fs.mkdir("logs");
}
await fs.writeFile(`logs/${name}.json`, JSON.stringify(data, null, 4), {encoding: "utf-8"});
} catch (err) {
console.error("Error saving log file: " + err);
}
}
_handleResponseErrors(res) {
console.warn(` Failed to get pull requests for '${API_REPOSITORY_ID}'; server responded with ${res.status} ${res.statusText}`);
const retry_header = res.headers.get("Retry-After");
if (retry_header) {
console.log(` Retry after: ${retry_header}`);
}
}
_handleDataErrors(data) {
if (typeof data["errors"] === "undefined") {
return;
}
console.warn(` Server handled the request, but there were errors:`);
data.errors.forEach((item) => {
console.log(` [${item.type}] ${item.message}`);
});
}
async fetchGithub(query) {
const init = {};
init.method = "POST";
init.headers = {};
init.headers["Content-Type"] = "application/json";
if (process.env.GRAPHQL_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GRAPHQL_TOKEN}`;
} else if (process.env.GITHUB_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`;
}
init.body = JSON.stringify({
query,
});
return await fetch("https://api.github.com/graphql", init);
}
async fetchGithubRest(query) {
const init = {};
init.method = "GET";
init.headers = {};
init.headers["Content-Type"] = "application/json";
if (process.env.GRAPHQL_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GRAPHQL_TOKEN}`;
} else if (process.env.GITHUB_TOKEN) {
init.headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`;
}
return await fetch(`${API_REST_PATH}${query}`, init);
}
async checkRates() {
try {
const query = `
query {
${API_RATE_LIMIT}
}
`;
const res = await this.fetchGithub(query);
if (res.status !== 200) {
this._handleResponseErrors(res);
process.exitCode = ExitCodes.RequestFailure;
return;
}
const data = await res.json();
await this._logResponse(data, "_rate_limit");
this._handleDataErrors(data);
const rate_limit = data.data["rateLimit"];
console.log(` [$${rate_limit.cost}] Available API calls: ${rate_limit.remaining}/${rate_limit.limit}; resets at ${rate_limit.resetAt}`);
} catch (err) {
console.error(" Error checking the API rate limits: " + err);
process.exitCode = ExitCodes.RequestFailure;
return;
}
}
async fetchPulls(page) {
try {
let after_cursor = "";
let after_text = "initial";
if (last_cursor !== "") {
after_cursor = `after: "${last_cursor}"`;
after_text = after_cursor;
}
const query = `
query {
${API_RATE_LIMIT}
repository(${API_REPOSITORY_ID}) {
pullRequests(first:${PULLS_PER_PAGE} ${after_cursor} states: OPEN) {
totalCount
pageInfo {
endCursor
hasNextPage
}
edges {
node {
id
number
url
title
state
isDraft
createdAt
updatedAt
baseRef {
name
}
author {
login
avatarUrl
url
... on User {
id
}
}
milestone {
id
title
url
}
labels (first: 100) {
edges {
node {
id
name
color
}
}
}
files (first: 100) {
edges {
node {
path
changeType
}
}
}
}
}
}
}
}
`;
let page_text = page;
if (page_count > 1) {
page_text = `${page}/${page_count}`;
}
console.log(` Requesting page ${page_text} of pull request data (${after_text}).`);
const res = await this.fetchGithub(query);
if (res.status !== 200) {
this._handleResponseErrors(res);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
const data = await res.json();
await this._logResponse(data, `data_page_${page}`);
this._handleDataErrors(data);
const rate_limit = data.data["rateLimit"];
const repository = data.data["repository"];
const pulls_data = mapNodes(repository.pullRequests);
console.log(` [$${rate_limit.cost}] Retrieved ${pulls_data.length} pull requests; processing...`);
last_cursor = repository.pullRequests.pageInfo.endCursor;
page_count = Math.ceil(repository.pullRequests.totalCount / PULLS_PER_PAGE);
return pulls_data;
} catch (err) {
console.error(" Error fetching pull request data: " + err);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
}
async fetchFiles(branch) {
try {
const query = `/git/trees/${branch}?recursive=1`;
const res = await this.fetchGithubRest(query);
if (res.status !== 200) {
this._handleResponseErrors(res);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
const data = await res.json();
await this._logResponse(data, `data_files_${branch}`);
this._handleDataErrors(data);
const files_data = data.tree;
console.log(` [$0] Retrieved ${files_data.length} file system entries in '${branch}'; processing...`);
return files_data;
} catch (err) {
console.error(" Error fetching pull request data: " + err);
process.exitCode = ExitCodes.RequestFailure;
return [];
}
}
}
class DataProcessor {
constructor() {
this.authors = {};
this.pulls = [];
this.files = [];
}
_explainFileType(type) {
switch(type) {
case "blob":
return "file";
case "tree":
return "folder";
default:
return "unknown";
}
}
processPulls(pullsRaw) {
try {
pullsRaw.forEach((item) => {
// Compile basic information about a PR.
let pr = {
"id": item.id,
"public_id": item.number,
"url": item.url,
"diff_url": `${item.url}.diff`,
"patch_url": `${item.url}.patch`,
"title": item.title,
"state": item.state,
"is_draft": item.isDraft,
"authored_by": null,
"created_at": item.createdAt,
"updated_at": item.updatedAt,
"target_branch": item.baseRef.name,
"labels": [],
"milestone": null,
"files": [],
};
// Compose and link author information.
const author = {
"id": "",
"user": "ghost",
"avatar": "https://avatars.githubusercontent.com/u/10137?v=4",
"url": "https://github.com/ghost",
"pull_count": 0,
};
if (item.author != null) {
author["id"] = item.author.id;
author["user"] = item.author.login;
author["avatar"] = item.author.avatarUrl;
author["url"] = item.author.url;
}
pr.authored_by = author.id;
// Store the author if they haven't been stored.
if (typeof this.authors[author.id] === "undefined") {
this.authors[author.id] = author;
}
this.authors[author.id].pull_count++;
// Add the milestone, if available.
if (item.milestone) {
pr.milestone = {
"id": item.milestone.id,
"title": item.milestone.title,
"url": item.milestone.url,
};
}
// Add labels, if available.
let labels = mapNodes(item.labels);
labels.forEach((labelItem) => {
pr.labels.push({
"id": labelItem.id,
"name": labelItem.name,
"color": "#" + labelItem.color,
});
});
pr.labels.sort((a, b) => {
if (a.name > b.name) return 1;
if (a.name < b.name) return -1;
return 0;
});
// Add changed files.
let files = mapNodes(item.files);
files.forEach((fileItem) => {
pr.files.push({
"path": fileItem.path,
"changeType": fileItem.changeType,
});
});
pr.files.sort((a, b) => {
if (a.name > b.name) return 1;
if (a.name < b.name) return -1;
return 0;
});
this.pulls.push(pr);
});
} catch (err) {
console.error(" Error parsing pull request data: " + err);
process.exitCode = ExitCodes.ParseFailure;
}
}
processFiles(filesRaw) {
try {
filesRaw.forEach((item) => {
let file = {
"type": this._explainFileType(item.type),
"name": item.path.split("/").pop(),
"path": item.path,
"parent": "",
};
let parentPath = item.path.split("/");
parentPath.pop();
if (parentPath.length > 0) {
file.parent = parentPath.join("/");
}
this.files.push(file);
});
} catch (err) {
console.error(" Error parsing repository file system: " + err);
process.exitCode = ExitCodes.ParseFailure;
}
}
}
function mapNodes(object) {
return object.edges.map((item) => item["node"])
}
async function main() {
// Internal utility methods.
const checkForExit = () => {
if (process.exitCode > 0) {
process.exit();
}
}
const delay = async (msec) => {
return new Promise(resolve => setTimeout(resolve, msec));
}
console.log("[*] Building local pull request database.");
const dataFetcher = new DataFetcher();
const dataProcessor = new DataProcessor();
console.log("[*] Checking the rate limits before.");
await dataFetcher.checkRates();
checkForExit();
// console.log("[*] Fetching pull request data from GitHub.");
// // Pages are starting with 1 for better presentation.
// let page = 1;
// while (page <= page_count) {
// const pullsRaw = await dataFetcher.fetchPulls(page);
// dataProcessor.processPulls(pullsRaw);
// checkForExit();
// page++;
// // Wait for a bit before proceeding to avoid hitting the secondary rate limit in GitHub API.
// // See https://docs.github.com/en/rest/guides/best-practices-for-integrators#dealing-with-secondary-rate-limits.
// await delay(1500);
// }
console.log("[*] Fetching repository file system from GitHub.");
const filesRaw = await dataFetcher.fetchFiles("master");
dataProcessor.processFiles(filesRaw);
checkForExit();
console.log("[*] Checking the rate limits after.")
await dataFetcher.checkRates();
checkForExit();
console.log("[*] Finalizing database.")
const output = {
"generated_at": Date.now(),
"authors": dataProcessor.authors,
"pulls": dataProcessor.pulls,
"files": dataProcessor.files,
};
try {
console.log("[*] Storing database to file.")
await fs.writeFile("out/data.json", JSON.stringify(output), {encoding: "utf-8"});
} catch (err) {
console.error("Error saving database file: " + err);
}
}
main();

1161
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "godot-prs-by-file",
"version": "1.0.0",
"description": "",
"scripts": {
"build": "rollup -c",
"compose-db": "node ./compose-db.js"
},
"author": "Yuri Sizov <yuris@humnom.net>",
"private": true,
"dependencies": {
"@babel/core": "^7.6.4",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-decorators": "^7.6.0",
"dompurify": "^2.0.7",
"lit-element": "^2.2.1",
"marked": "^0.7.0",
"node-fetch": "^2.6.1",
"posthtml": "^0.12.0",
"rollup": "^1.24.0",
"rollup-plugin-babel": "^4.3.3",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-includepaths": "^0.2.3",
"rollup-plugin-node-resolve": "^5.2.0"
}
}

116
rollup.config.js Normal file
View File

@@ -0,0 +1,116 @@
import { promises as fs } from 'fs';
import path from 'path';
import nodeResolve from 'rollup-plugin-node-resolve';
import includePaths from 'rollup-plugin-includepaths'
import commonjs from 'rollup-plugin-commonjs';
import copy from 'rollup-plugin-copy';
import posthtmlTemplate from './build/rollup-posthtml-template';
import babel from 'rollup-plugin-babel';
const INPUT_ROOT = 'src';
const INPUT_PATHS_ROOT = path.join(INPUT_ROOT, 'paths');
const INPUT_SHARED_ROOT = path.join(INPUT_ROOT, 'shared');
const INPUT_STATIC_ROOT = path.join(INPUT_ROOT, 'static');
const ENTRY_FILE_NAME = 'entry.js';
const TEMPLATE_FILE_NAME = 'template.html';
const GLOBAL_FILE_NAME = 'global.js';
const OUTPUT_ROOT = 'out';
const OUTPUT_STYLES = path.join(OUTPUT_ROOT, 'styles');
const OUTPUT_STYLES_SHARED = path.join(OUTPUT_STYLES, 'shared');
const OUTPUT_SCRIPTS = path.join(OUTPUT_ROOT, 'scripts');
const OUTPUT_SCRIPTS_SHARED = path.join(OUTPUT_SCRIPTS, 'shared');
const generateConfig = async () => {
let configs = [];
getGlobalConfig(configs);
await getPathsConfigs(configs);
return configs;
};
const getGlobalConfig = (configs) => {
const globalScriptPath = path.join(INPUT_SHARED_ROOT, 'scripts', GLOBAL_FILE_NAME);
const outputPath = path.join(OUTPUT_SCRIPTS_SHARED, GLOBAL_FILE_NAME);
const sharedStylesGlob = path.join(INPUT_SHARED_ROOT, 'styles/**/*.css').replace(/\\/g, '/'); // Windows path not supported by copy plugin
const staticGlob = path.join(INPUT_STATIC_ROOT, '/**/*.*').replace(/\\/g, '/'); // Windows path not supported by copy plugin
configs.push({
input: globalScriptPath,
output: {
name: 'global',
file: outputPath,
format: 'iife'
},
plugins: [
nodeResolve(),
copy({
targets: [
{ src: sharedStylesGlob, dest: OUTPUT_STYLES_SHARED },
{ src: staticGlob, dest: OUTPUT_ROOT },
],
verbose: true
})
]
})
};
const getPathsConfigs = async (configs) => {
try {
// Collect paths to process
const paths = await fs.readdir(INPUT_PATHS_ROOT);
for (const itemPath of paths) {
const itemRoot = path.join(INPUT_PATHS_ROOT, itemPath);
const itemFiles = await fs.readdir(itemRoot);
if (itemFiles.indexOf(ENTRY_FILE_NAME) < 0) {
throw Error(`Missing entry script for "${itemPath}" path`);
}
if (itemFiles.indexOf(TEMPLATE_FILE_NAME) < 0) {
throw Error(`Missing HTML template for "${itemPath}" path`);
}
const entryPath = path.join(itemRoot, ENTRY_FILE_NAME);
const templatePath = path.join(itemRoot, TEMPLATE_FILE_NAME).replace(/\\/g, '/'); // Windows path not supported by copy plugin
const bundlePath = path.join(OUTPUT_ROOT, 'scripts', `${itemPath}.js`);
const htmlPath = path.join(OUTPUT_ROOT, `${itemPath}.html`);
configs.push({
input: entryPath,
output: {
name: itemPath,
file: bundlePath,
format: 'iife'
},
plugins: [
babel({
exclude: 'node_modules/**',
plugins: [
[ '@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true } ],
'@babel/plugin-proposal-class-properties'
]
}),
includePaths({
paths: [ './' ]
}),
nodeResolve(),
commonjs({
sourceMap: false
}),
posthtmlTemplate({
src: templatePath,
dest: htmlPath
})
]
})
}
} catch (exc) {
console.error(exc);
}
};
export default generateConfig();

View File

@@ -0,0 +1,83 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
@customElement('gr-index-description')
export default class IndexDescription extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
}
@media (prefers-color-scheme: dark) {
:host {
}
}
/** Component styling **/
:host {
line-height: 22px;
}
:host .header-description {
display: flex;
align-items: flex-end;
color: var(--dimmed-font-color);
}
:host .header-description-column {
flex: 2;
}
:host .header-description-column.header-extra-links {
flex: 1;
text-align: right;
}
:host .header-description a {
color: var(--link-font-color);
text-decoration: none;
}
:host .header-description a:hover {
color: var(--link-font-color-hover);
}
:host hr {
border: none;
border-top: 1px solid var(--g-background-extra-color);
width: 30%;
}
@media only screen and (max-width: 900px) {
:host .header-description {
padding: 0 8px;
flex-direction: column;
}
:host .header-description-column {
width: 100%;
}
:host .header-description-column.header-extra-links {
text-align: center;
padding-top: 12px;
}
}
`;
}
@property({ type: Date }) generated_at = null;
render() {
return html`
<div class="header-description">
<div class="header-description-column">
This page lists all open pull-requests (PRs) associated with the selected file
or folder. The goal here is to help contributors and maintainers identify possible
conflicts and duplication.
</div>
<div class="header-description-column header-extra-links">
See also:
<br />
<a href="https://godotengine.github.io/godot-team-reports/" target="_blank">Godot Team Reports</a>
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,116 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
@customElement('gr-index-entry')
export default class IndexHeader extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--header-meta-color: #98a5b8;
}
@media (prefers-color-scheme: dark) {
:host {
--header-meta-color: #515c6c;
}
}
/** Component styling **/
:host {
}
:host .header {
display: flex;
justify-content: space-between;
align-items: center;
}
:host .header-metadata {
color: var(--header-meta-color);
text-align: right;
}
:host .header-metadata a {
color: var(--link-font-color);
text-decoration: none;
}
:host .header-metadata a:hover {
color: var(--link-font-color-hover);
}
@media only screen and (max-width: 900px) {
:host .header {
flex-wrap: wrap;
text-align: center;
}
:host .header-title,
:host .header-metadata {
width: 100%;
}
:host .header-metadata {
padding-bottom: 12px;
text-align: center;
}
}
`;
}
@property({ type: Date }) generated_at = null;
constructor() {
super();
// Auto-refresh about once a minute so that the relative time of generation is always actual.
this._refreshTimeout = setTimeout(this._refresh.bind(this), 60 * 1000);
}
_refresh() {
this.requestUpdate();
// Continue updating.
this._refreshTimeout = setTimeout(this._refresh.bind(this), 60 * 1000);
}
render() {
let generatedAt = "";
let generatedRel = "";
if (this.generated_at) {
generatedAt = greports.format.formatTimestamp(this.generated_at);
let timeValue = (Date.now() - this.generated_at) / (1000 * 60);
let timeUnit = "minute";
if (timeValue < 1) {
generatedRel = "just now";
} else {
if (timeValue > 60) {
timeValue = timeValue / 60;
timeUnit = "hour";
}
generatedRel = greports.format.formatTimespan(-Math.round(timeValue), timeUnit);
}
}
return html`
<div class="header">
<h1 class="header-title">
Godot PRs by File
</h1>
<div class="header-metadata">
${(this.generated_at ? html`
<span title="${generatedAt}">
data generated ${generatedRel}
</span>
` : '')}
<br/>
<a
href="https://github.com/godotengine/godot-prs-by-file"
target="_blank"
>
contribute on GitHub
</a>
</div>
</div>
`;
}
}

View File

@@ -0,0 +1,121 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
@customElement('gr-file-item')
export default class FileItem extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--tab-hover-background-color: rgba(0, 0, 0, 0.14);
--tab-active-background-color: #d6e6ff;
--tab-active-border-color: #397adf;
}
@media (prefers-color-scheme: dark) {
:host {
--tab-hover-background-color: rgba(255, 255, 255, 0.14);
--tab-active-background-color: #2c3c55;
--tab-active-border-color: #397adf;
}
}
/** Component styling **/
:host {
max-width: 240px;
}
:host .file-item {
border-left: 5px solid transparent;
color: var(--g-font-color);
cursor: pointer;
display: flex;
flex-direction: row;
gap: 8px;
padding: 3px 12px;
align-items: center;
}
:host .file-item:hover {
background-color: var(--tab-hover-background-color);
}
:host .file-item--active {
background-color: var(--tab-active-background-color);
border-left: 5px solid var(--tab-active-border-color);
}
:host .file-icon {
background-size: cover;
border-radius: 2px;
display: inline-block;
width: 16px;
height: 16px;
min-width: 16px;
}
:host .file-icon--folder {
background-image: url('/folder.svg');
}
:host .file-icon--file {
background-image: url('/file.svg');
filter: brightness(0.5);
}
:host .file-title {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
}
:host .file-pull-count {
color: var(--dimmed-font-color);
flex-grow: 1;
font-size: 13px;
text-align: right;
}
:host .file-pull-count--hot {
color: var(--g-font-color);
font-weight: 700;
}
@media only screen and (max-width: 900px) {
:host .file-item {
padding: 6px 16px;
}
:host .file-title,
:host .file-pull-count {
font-size: 16px;
}
}
`;
}
@property({ type: String }) path = "";
@property({ type: String, reflect: true }) name = "";
@property({ type: String, reflect: true }) type = "";
@property({ type: Boolean, reflect: true }) active = false;
@property({ type: Number }) pull_count = 0;
render(){
const classList = [ "file-item" ];
if (this.active) {
classList.push("file-item--active");
}
const iconClassList = [ "file-icon", "file-icon--" + this.type ];
const countClassList = [ "file-pull-count" ];
if (this.pull_count > 50) {
countClassList.push("file-pull-count--hot");
}
return html`
<div class="${classList.join(" ")}">
<div class="${iconClassList.join(" ")}"></div>
<span class="file-title">
${this.path}
</span>
<span class="${countClassList.join(" ")}">
${this.pull_count}
</span>
</div>
`;
}
}

View File

@@ -0,0 +1,76 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
import FileItem from "./FileItem";
@customElement('gr-file-list')
export default class FileList extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
--files-background-color: #fcfcfa;
--files-border-color: #515c6c;
}
@media (prefers-color-scheme: dark) {
:host {
--files-background-color: #0d1117;
--files-border-color: #515c6c;
}
}
/** Component styling **/
:host {
}
:host .file-list {
background-color: var(--files-background-color);
border-right: 2px solid var(--files-border-color);
width: 320px;
min-height: 216px;
}
@media only screen and (max-width: 900px) {
:host {
width: 100%
}
:host .file-list {
width: 100% !important;
}
}
`;
}
@property({ type: Object }) files = {};
@property({ type: String }) selected = "";
constructor() {
super();
}
render() {
const topLevel = this.files[""] || [];
return html`
<div class="file-list">
<div class="file-list-section">
${(topLevel.length > 0) ?
topLevel.map((item) => {
return html`
<gr-file-item
.path="${item.path}"
.name="${item.name}"
.type="${item.type}"
.pull_count="${item.pull_count}"
?active="${false}"
/>
`;
}) : html`
<span>There are no files</span>
`
}
</div>
</div>
`;
}
}

110
src/paths/index/entry.js Normal file
View File

@@ -0,0 +1,110 @@
import { LitElement, html, css, customElement, property } from 'lit-element';
import PageContent from 'src/shared/components/PageContent';
import IndexHeader from "./components/IndexHeader";
import IndexDescription from "./components/IndexDescription";
import FileList from "./components/files/FileList";
@customElement('entry-component')
export default class EntryComponent extends LitElement {
static get styles() {
return css`
/** Colors and variables **/
:host {
}
@media (prefers-color-scheme: dark) {
:host {
}
}
/** Component styling **/
:host {
}
`;
}
constructor() {
super();
this._entryRequested = false;
this._isLoading = true;
this._generatedAt = null;
this._files = {};
this._requestData();
}
performUpdate() {
this._requestData();
super.performUpdate();
}
async _requestData() {
if (this._entryRequested) {
return;
}
this._entryRequested = true;
this._isLoading = true;
const data = await greports.api.getData();
if (data) {
this._generatedAt = data.generated_at;
data.files.forEach((file) => {
if (file.type === "file" || file.type === "folder") {
if (typeof this._files[file.parent] === "undefined") {
this._files[file.parent] = [];
}
this._files[file.parent].push(file);
}
});
for (let folderName in this._files) {
this._files[folderName].sort((a, b) => {
if (a.type === "folder" && b.type !== "folder") {
return -1;
}
if (b.type === "folder" && a.type !== "folder") {
return 1;
}
const a_name = a.path.toLowerCase();
const b_name = b.path.toLowerCase();
if (a_name > b_name) return 1;
if (a_name < b_name) return -1;
return 0;
});
}
} else {
this._generatedAt = null;
this._files = [];
}
this._isLoading = false;
this.requestUpdate();
}
render(){
return html`
<page-content>
<gr-index-entry .generated_at="${this._generatedAt}"></gr-index-entry>
<gr-index-description></gr-index-description>
${(this._isLoading ? html`
<h3>Loading...</h3>
` : html`
<div class="files">
<gr-file-list
.files="${this._files}"
></gr-file-list>
</div>
`)}
</page-content>
`;
}
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<include src="shared/partials/head_content.html"></include>
<title>Godot PRs by File</title>
<meta name="description" content="Godot Engine PRs grouped by individual source files they affect">
<meta name="keywords" content="godot, godot engine, gamedev, project management">
<script src="scripts/index.js"></script>
</head>
<body>
<entry-component></entry-component>
<include src="shared/partials/body_content.html"></include>
</body>
</html>

View File

@@ -0,0 +1,28 @@
import { LitElement, html, css, customElement } from 'lit-element';
@customElement('page-content')
export default class PageContent extends LitElement {
static get styles() {
return css`
/** Component styling **/
:host {
display: block;
margin: 0 auto;
padding: 0 12px;
max-width: 1024px;
}
@media only screen and (max-width: 900px) {
:host {
padding: 0;
}
}
`;
}
render(){
return html`
<slot></slot>
`;
}
}

View File

View File

@@ -0,0 +1,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500i,700,900&display=swap&subset=latin-ext" rel="stylesheet">
<link rel="icon" type="image/png" href="favicon.png" />
<link href="styles/shared/normalize.css" rel="stylesheet">
<link href="styles/shared/global.css" rel="stylesheet">
<script src="scripts/shared/global.js"></script>

View File

@@ -0,0 +1,124 @@
const LOCAL_PREFERENCE_PREFIX = "_godot_prbf"
const LOCAL_PREFERENCE_DEFAULTS = {
};
// API Interaction
const ReportsAPI = {
async get(path = '/') {
const res = await fetch(`${path}`);
if (res.status !== 200) {
return null;
}
return await res.json();
},
async getData() {
return await this.get("data.json");
},
};
// Content helpers
const ReportsFormatter = {
formatDate(dateString) {
const options = {
year: 'numeric', month: 'long', day: 'numeric',
};
const dateFormatter = new Intl.DateTimeFormat('en-US', options);
const date = new Date(dateString);
return dateFormatter.format(date);
},
formatTimestamp(timeString) {
const options = {
year: 'numeric', month: 'long', day: 'numeric',
hour: 'numeric', hour12: false, minute: 'numeric',
timeZone: 'UTC', timeZoneName: 'short',
};
const dateFormatter = new Intl.DateTimeFormat('en-US', options);
const date = new Date(timeString);
return dateFormatter.format(date);
},
formatTimespan(timeValue, timeUnit) {
const options = {
style: 'long',
};
const timeFormatter = new Intl.RelativeTimeFormat('en-US', options);
return timeFormatter.format(timeValue, timeUnit);
},
getDaysSince(dateString) {
const date = new Date(dateString);
const msBetween = (new Date()) - date;
const days = Math.floor(msBetween / (1000 * 60 * 60 * 24));
return days;
},
formatDays(days) {
return days + " " + (days !== 1 ? "days" : "day");
},
};
const ReportsUtils = {
createEvent(name, detail = {}) {
return new CustomEvent(name, {
detail: detail
});
},
getHistoryHash() {
let rawHash = window.location.hash;
if (rawHash !== "") {
return rawHash.substr(1);
}
return "";
},
setHistoryHash(hash) {
const url = new URL(window.location);
url.hash = hash;
window.history.pushState({}, "", url);
},
getLocalPreferences() {
// Always fallback on defaults.
const localPreferences = { ...LOCAL_PREFERENCE_DEFAULTS };
for (let key in localPreferences) {
const storedValue = localStorage.getItem(`${LOCAL_PREFERENCE_PREFIX}_${key}`);
if (storedValue != null) {
localPreferences[key] = JSON.parse(storedValue);
}
}
return localPreferences;
},
setLocalPreferences(currentPreferences) {
for (let key in currentPreferences) {
// Only store known properties.
if (key in LOCAL_PREFERENCE_DEFAULTS) {
localStorage.setItem(`${LOCAL_PREFERENCE_PREFIX}_${key}`, JSON.stringify(currentPreferences[key]));
}
}
},
resetLocalPreferences() {
this.setLocalPreferences(LOCAL_PREFERENCE_DEFAULTS);
},
};
const ReportsSingleton = {
api: ReportsAPI,
format: ReportsFormatter,
util: ReportsUtils,
};
window.greports = ReportsSingleton;

View File

@@ -0,0 +1,53 @@
/** Colors and variables **/
:root {
--g-background-color: #fcfcfa;
--g-background-extra-color: #98a5b8;
--g-font-color: #121314;
--g-font-size: 15px;
--g-font-weight: 400;
--g-line-height: 20px;
--link-font-color: #1d6dff;
--link-font-color-hover: #1051c9;
--link-font-color-inactive: #35496f;
--dimmed-font-color: #535c5f;
--light-font-color: #6b7893;
}
@media (prefers-color-scheme: dark) {
:root {
--g-background-color: #0d1117;
--g-background-extra-color: #515c6c;
--g-font-color: rgba(228, 228, 232, 0.9);
--link-font-color: #367df7;
--link-font-color-hover: #6391ec;
--link-font-color-inactive: #abbdcc;
--dimmed-font-color: #929da0;
--light-font-color: #8491ab;
}
}
/** General styling **/
html {}
body {
background: var(--g-background-color);
color: var(--g-font-color);
font-family: 'Roboto', sans-serif;
font-size: var(--g-font-size);
font-weight: var(--g-font-weight);
line-height: var(--g-line-height);
min-width: 380px;
}
a {
color: var(--link-font-color);
font-weight: 700;
text-decoration: none;
}
a:hover {
color: var(--link-font-color-hover);
}

349
src/shared/styles/normalize.css vendored Normal file
View File

@@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

BIN
src/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m2 1v14h12v-9h-5v-5zm8 0v4h4z" fill="#e0e0e0" transform="translate(0 -.000017)"/></svg>

After

Width:  |  Height:  |  Size: 180 B

View File

@@ -0,0 +1 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m2 2a1 1 0 0 0 -1 1v2 6 2a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7a1 1 0 0 0 -1-1h-4a1 1 0 0 1 -1-1v-1a1 1 0 0 0 -1-1z" fill="#e0e0e0"/></svg>

After

Width:  |  Height:  |  Size: 228 B