├── src ├── shared │ ├── partials │ │ ├── body_content.html │ │ └── head_content.html │ ├── components │ │ ├── PageContent.js │ │ └── SharedNavigation.js │ ├── styles │ │ ├── global.css │ │ └── normalize.css │ └── scripts │ │ └── global.js ├── static │ ├── favicon.png │ └── icons │ │ ├── file.svg │ │ ├── filesystem.svg │ │ ├── remove.svg │ │ ├── folder.svg │ │ └── hamburger.svg └── paths │ └── index │ ├── template.html │ ├── components │ ├── IndexDescription.js │ ├── files │ │ ├── RootItem.js │ │ ├── FileItem.js │ │ └── FileList.js │ ├── IndexHeader.js │ ├── filters │ │ └── PullFilter.js │ └── pulls │ │ ├── PullRequestList.js │ │ └── PullRequestItem.js │ └── entry.js ├── .gitignore ├── package.json ├── LICENSE.md ├── .github └── workflows │ └── ci.yml ├── README.md ├── rollup.config.js └── compose-db.js /src/shared/partials/body_content.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godotengine/godot-prs-by-file/HEAD/src/static/favicon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project folders. 2 | node_modules/ 3 | out/ 4 | logs/ 5 | 6 | # Development environments. 7 | .idea/ 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /src/static/icons/file.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/static/icons/filesystem.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/static/icons/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/static/icons/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/static/icons/hamburger.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/shared/partials/head_content.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/paths/index/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Godot PRs by File 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/shared/components/PageContent.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement } from 'lit-element'; 2 | 3 | @customElement('page-content') 4 | export default class PageContent extends LitElement { 5 | static get styles() { 6 | return css` 7 | /** Component styling **/ 8 | :host { 9 | display: block; 10 | margin: 0 auto; 11 | padding: 0 12px; 12 | max-width: 1024px; 13 | } 14 | 15 | @media only screen and (max-width: 900px) { 16 | :host { 17 | padding: 0; 18 | } 19 | } 20 | `; 21 | } 22 | 23 | render(){ 24 | return html` 25 | 26 | `; 27 | } 28 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "godot-prs-by-file", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "rollup -c", 7 | "compose-db": "node ./compose-db.js" 8 | }, 9 | "author": "Yuri Sizov ", 10 | "private": true, 11 | "dependencies": { 12 | "@babel/core": "^7.6.4", 13 | "@babel/plugin-proposal-class-properties": "^7.5.5", 14 | "@babel/plugin-proposal-decorators": "^7.6.0", 15 | "dompurify": "^2.0.7", 16 | "lit-element": "^2.2.1", 17 | "marked": "^0.7.0", 18 | "node-fetch": "^2.7.0", 19 | "posthtml": "^0.12.0", 20 | "rollup": "^1.24.0", 21 | "rollup-plugin-babel": "^4.3.3", 22 | "rollup-plugin-commonjs": "^10.1.0", 23 | "rollup-plugin-copy": "^3.4.0", 24 | "rollup-plugin-includepaths": "^0.2.3", 25 | "rollup-plugin-node-resolve": "^5.2.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright © 2023-present Godot Engine contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/shared/styles/global.css: -------------------------------------------------------------------------------- 1 | /** Colors and variables **/ 2 | :root { 3 | --g-background-color: #fcfcfa; 4 | --g-background-extra-color: #98a5b8; 5 | --g-background-extra2-color: #cad3e1; 6 | --g-font-color: #121314; 7 | --g-font-size: 15px; 8 | --g-font-weight: 400; 9 | --g-line-height: 20px; 10 | 11 | --link-font-color: #1d6dff; 12 | --link-font-color-hover: #1051c9; 13 | --link-font-color-inactive: #35496f; 14 | 15 | --dimmed-font-color: #535c5f; 16 | --light-font-color: #6b7893; 17 | } 18 | 19 | @media (prefers-color-scheme: dark) { 20 | :root { 21 | --g-background-color: #0d1117; 22 | --g-background-extra-color: #515c6c; 23 | --g-background-extra2-color: #22252b; 24 | --g-font-color: rgba(228, 228, 232, 0.9); 25 | 26 | --link-font-color: #367df7; 27 | --link-font-color-hover: #6391ec; 28 | --link-font-color-inactive: #abbdcc; 29 | 30 | --dimmed-font-color: #929da0; 31 | --light-font-color: #8491ab; 32 | } 33 | } 34 | 35 | /** General styling **/ 36 | html {} 37 | 38 | body { 39 | background: var(--g-background-color); 40 | color: var(--g-font-color); 41 | font-family: 'Roboto', sans-serif; 42 | font-size: var(--g-font-size); 43 | font-weight: var(--g-font-weight); 44 | line-height: var(--g-line-height); 45 | min-width: 380px; 46 | } 47 | 48 | a { 49 | color: var(--link-font-color); 50 | font-weight: 700; 51 | text-decoration: none; 52 | } 53 | a:hover { 54 | color: var(--link-font-color-hover); 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | schedule: 8 | # Run every 15 minutes of every hour, starting at 5 minutes (5m, 20m, 35m, 50m). 9 | # The slight offset is there to try and avoid the high load times. 10 | - cron: '5/15 * * * *' 11 | 12 | # Make sure jobs cannot overlap (e.g. one from push and one from schedule). 13 | concurrency: 14 | group: pages-ci 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | name: Build and deploy to GitHub Pages 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Install Node.js 16.x 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 16.x 29 | cache: 'npm' 30 | 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Build the static content using npm 35 | run: npm run build 36 | 37 | - name: Fetch pull request data (godot) 38 | run: npm run compose-db -- repo:godot 39 | env: 40 | GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }} 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Fetch pull request data (godot-docs) 44 | run: npm run compose-db -- repo:godot-docs 45 | env: 46 | GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }} 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Archive production artifacts 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: web-static 53 | path: out 54 | 55 | - name: Deploy to GitHub Pages 🚀 56 | if: github.event_name != 'pull_request' 57 | uses: JamesIves/github-pages-deploy-action@v4 58 | with: 59 | branch: gh-pages 60 | folder: out 61 | # Configure the commit author. 62 | git-config-name: 'Godot Organization' 63 | git-config-email: '<>' 64 | # Don't keep the history. 65 | single-commit: true 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Godot PRs by File 2 | 3 | This project is provided for Godot Engine contributors to quickly find open 4 | PRs editing a specific file or folder. With the amount of work that goes into 5 | Godot it becomes tricky to keep in mind every PR that touches every file, and 6 | identify conflicts or duplicates. This project aims to help with that. 7 | 8 | Live website: https://godotengine.github.io/godot-prs-by-file/ 9 | 10 | ## Contributing 11 | 12 | This project is written in JavaScript and is built using Node.JS. HTML and CSS are 13 | used for the presentation. The end result of the build process is completely static 14 | and can be server from any web server, no Node.JS required. 15 | 16 | Front-end is designed in a reactive manner using industry standard Web Components 17 | (powered by `lit-element`). This provides native browser support, and results in a 18 | small overhead from the build process. 19 | 20 | To build the project locally you need to have Node.JS installed (12.x and newer 21 | should work just fine). 22 | 23 | This project uses GitHub's GraphQL API. To fetch live data you need to generate 24 | a [personal OAuth token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). 25 | You can supply your token to the scripts using the `GRAPHQL_TOKEN` environment 26 | variable. Note, that if you don't have member access to the organization, you 27 | may not be able to access all the information used when generating the database. 28 | 29 | 1. Clone or download the project. 30 | 2. From the project root run `npm install` or `yarn` to install dependencies. 31 | 3. Run `npm run build` or `yarn run build` to build the pages. 32 | 4. Run `npm run compose-db` or `yarn run compose-db` to fetch the data from GitHub. 33 | 5. Serve the `out/` folder with your method of choice (e.g. using Python 3: 34 | `python -m http.server 8080 -d ./out`). 35 | 36 | `rollup` is used for browser packing of scripts and copying of static assets. The 37 | data fetching script is plain JavaScript with `node-fetch` used to polyfill 38 | `fetch()`-like API. 39 | 40 | ## License 41 | 42 | This project is provided under the [MIT License](LICENSE.md). 43 | -------------------------------------------------------------------------------- /src/paths/index/components/IndexDescription.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | @customElement('gr-index-description') 4 | export default class IndexDescription extends LitElement { 5 | static get styles() { 6 | return css` 7 | /** Colors and variables **/ 8 | :host { 9 | } 10 | @media (prefers-color-scheme: dark) { 11 | :host { 12 | } 13 | } 14 | 15 | /** Component styling **/ 16 | :host { 17 | line-height: 22px; 18 | } 19 | 20 | :host .header-description { 21 | display: flex; 22 | align-items: flex-end; 23 | color: var(--dimmed-font-color); 24 | } 25 | 26 | :host .header-description-column { 27 | flex: 2; 28 | } 29 | :host .header-description-column.header-extra-links { 30 | flex: 1; 31 | text-align: right; 32 | } 33 | 34 | :host .header-description a { 35 | color: var(--link-font-color); 36 | text-decoration: none; 37 | } 38 | :host .header-description a:hover { 39 | color: var(--link-font-color-hover); 40 | } 41 | 42 | :host hr { 43 | border: none; 44 | border-top: 1px solid var(--g-background-extra-color); 45 | width: 30%; 46 | } 47 | 48 | @media only screen and (max-width: 900px) { 49 | :host .header-description { 50 | padding: 0 8px; 51 | flex-direction: column; 52 | } 53 | 54 | :host .header-description-column { 55 | width: 100%; 56 | } 57 | :host .header-description-column.header-extra-links { 58 | text-align: center; 59 | padding-top: 12px; 60 | } 61 | } 62 | `; 63 | } 64 | 65 | @property({ type: Date }) generated_at = null; 66 | 67 | constructor() { 68 | super(); 69 | 70 | this._availableRepos = [ 71 | "godotengine/godot", 72 | "godotengine/godot-docs", 73 | ]; 74 | } 75 | 76 | render() { 77 | return html` 78 |
79 |
80 | This page lists all open pull-requests (PRs) associated with the selected file 81 | or folder. 82 |
83 | The goal here is to help contributors and maintainers identify possible 84 | conflicts and duplication. 85 |
86 | 98 |
99 | `; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/shared/scripts/global.js: -------------------------------------------------------------------------------- 1 | const LOCAL_PREFERENCE_PREFIX = "_godot_prbf" 2 | const LOCAL_PREFERENCE_DEFAULTS = { 3 | "selectedRepository" : "godotengine/godot", 4 | "selectedBranches" : {"godotengine/godot": "master"}, 5 | }; 6 | 7 | // API Interaction 8 | const ReportsAPI = { 9 | async get(path = '/') { 10 | const res = await fetch(`${path}`); 11 | if (res.status !== 200) { 12 | return null; 13 | } 14 | 15 | return await res.json(); 16 | }, 17 | 18 | async getData(repositoryId) { 19 | const idBits = repositoryId.split("/"); 20 | 21 | return await this.get(`${idBits[0]}.${idBits[1]}.data.json`); 22 | }, 23 | }; 24 | 25 | // Content helpers 26 | const ReportsFormatter = { 27 | formatDate(dateString) { 28 | const options = { 29 | year: 'numeric', month: 'long', day: 'numeric', 30 | }; 31 | const dateFormatter = new Intl.DateTimeFormat('en-US', options); 32 | 33 | const date = new Date(dateString); 34 | return dateFormatter.format(date); 35 | }, 36 | 37 | formatTimestamp(timeString) { 38 | const options = { 39 | year: 'numeric', month: 'long', day: 'numeric', 40 | hour: 'numeric', hour12: false, minute: 'numeric', 41 | timeZone: 'UTC', timeZoneName: 'short', 42 | }; 43 | const dateFormatter = new Intl.DateTimeFormat('en-US', options); 44 | 45 | const date = new Date(timeString); 46 | return dateFormatter.format(date); 47 | }, 48 | 49 | formatTimespan(timeValue, timeUnit) { 50 | const options = { 51 | style: 'long', 52 | }; 53 | const timeFormatter = new Intl.RelativeTimeFormat('en-US', options); 54 | 55 | return timeFormatter.format(timeValue, timeUnit); 56 | }, 57 | 58 | getDaysSince(dateString) { 59 | const date = new Date(dateString); 60 | const msBetween = (new Date()) - date; 61 | const days = Math.floor(msBetween / (1000 * 60 * 60 * 24)); 62 | 63 | return days; 64 | }, 65 | 66 | formatDays(days) { 67 | return days + " " + (days !== 1 ? "days" : "day"); 68 | }, 69 | }; 70 | 71 | const ReportsUtils = { 72 | createEvent(name, detail = {}) { 73 | return new CustomEvent(name, { 74 | detail: detail 75 | }); 76 | }, 77 | 78 | getHistoryHash() { 79 | let rawHash = window.location.hash; 80 | if (rawHash !== "") { 81 | return rawHash.substr(1); 82 | } 83 | 84 | return ""; 85 | }, 86 | 87 | setHistoryHash(hash) { 88 | const url = new URL(window.location); 89 | url.hash = hash; 90 | window.history.pushState({}, "", url); 91 | }, 92 | 93 | navigateHistoryHash(hash) { 94 | this.setHistoryHash(hash); 95 | window.location.reload(); 96 | }, 97 | 98 | getLocalPreferences() { 99 | // Always fallback on defaults. 100 | const localPreferences = { ...LOCAL_PREFERENCE_DEFAULTS }; 101 | 102 | for (let key in localPreferences) { 103 | const storedValue = localStorage.getItem(`${LOCAL_PREFERENCE_PREFIX}_${key}`); 104 | if (storedValue != null) { 105 | localPreferences[key] = JSON.parse(storedValue); 106 | } 107 | } 108 | 109 | return localPreferences; 110 | }, 111 | 112 | setLocalPreferences(currentPreferences) { 113 | for (let key in currentPreferences) { 114 | // Only store known properties. 115 | if (key in LOCAL_PREFERENCE_DEFAULTS) { 116 | localStorage.setItem(`${LOCAL_PREFERENCE_PREFIX}_${key}`, JSON.stringify(currentPreferences[key])); 117 | } 118 | } 119 | }, 120 | 121 | resetLocalPreferences() { 122 | this.setLocalPreferences(LOCAL_PREFERENCE_DEFAULTS); 123 | }, 124 | }; 125 | 126 | const ReportsSingleton = { 127 | api: ReportsAPI, 128 | format: ReportsFormatter, 129 | util: ReportsUtils, 130 | }; 131 | 132 | window.greports = ReportsSingleton; -------------------------------------------------------------------------------- /src/paths/index/components/files/RootItem.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | @customElement('gr-root-item') 4 | export default class RootItem extends LitElement { 5 | static get styles() { 6 | return css` 7 | /** Colors and variables **/ 8 | :host { 9 | --tab-hover-background-color: rgba(0, 0, 0, 0.14); 10 | } 11 | @media (prefers-color-scheme: dark) { 12 | :host { 13 | --tab-hover-background-color: rgba(255, 255, 255, 0.14); 14 | } 15 | } 16 | 17 | /** Component styling **/ 18 | :host { 19 | max-width: 240px; 20 | } 21 | 22 | :host .root-item { 23 | color: var(--g-font-color); 24 | cursor: pointer; 25 | display: flex; 26 | flex-direction: row; 27 | gap: 8px; 28 | padding: 6px 12px 6px 6px; 29 | align-items: center; 30 | } 31 | :host .root-item:hover { 32 | background-color: var(--tab-hover-background-color); 33 | } 34 | 35 | :host .root-icon { 36 | background-image: url('filesystem.svg'); 37 | background-size: 16px 16px; 38 | background-position: 50% 50%; 39 | background-repeat: no-repeat; 40 | border-radius: 2px; 41 | display: inline-block; 42 | width: 20px; 43 | height: 20px; 44 | min-width: 20px; 45 | } 46 | 47 | @media (prefers-color-scheme: light) { 48 | :host .root-icon { 49 | filter: brightness(0.5); 50 | } 51 | } 52 | 53 | :host .root-title { 54 | font-size: 14px; 55 | font-weight: 600; 56 | white-space: nowrap; 57 | overflow: hidden; 58 | } 59 | 60 | :host .root-branch { 61 | color: var(--link-font-color); 62 | flex-grow: 1; 63 | font-size: 14px; 64 | font-weight: 600; 65 | text-align: right; 66 | } 67 | :host .root-branch:hover { 68 | color: var(--link-font-color-hover); 69 | } 70 | 71 | @media only screen and (max-width: 900px) { 72 | :host .root-item { 73 | padding: 10px 16px 10px 12px; 74 | } 75 | 76 | :host .root-title, 77 | :host .root-branch { 78 | font-size: 16px; 79 | } 80 | } 81 | `; 82 | } 83 | 84 | @property({ type: String }) repository = ""; 85 | @property({ type: String, reflect: true }) branch = ""; 86 | 87 | _onIconClicked(event) { 88 | event.preventDefault(); 89 | event.stopPropagation(); 90 | this.dispatchEvent(greports.util.createEvent("iconclick"), {}); 91 | } 92 | 93 | _onBranchClicked(event) { 94 | event.preventDefault(); 95 | event.stopPropagation(); 96 | this.dispatchEvent(greports.util.createEvent("branchclick"), {}); 97 | } 98 | 99 | render(){ 100 | return html` 101 |
102 |
106 | 107 | ${this.repository} 108 | 109 | 113 | ${this.branch} 114 | 115 |
116 | `; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/paths/index/components/IndexHeader.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | @customElement('gr-index-entry') 4 | export default class IndexHeader extends LitElement { 5 | static get styles() { 6 | return css` 7 | /** Colors and variables **/ 8 | :host { 9 | --header-meta-color: #98a5b8; 10 | } 11 | @media (prefers-color-scheme: dark) { 12 | :host { 13 | --header-meta-color: #515c6c; 14 | } 15 | } 16 | 17 | /** Component styling **/ 18 | :host { 19 | } 20 | 21 | :host .header { 22 | display: flex; 23 | justify-content: space-between; 24 | align-items: center; 25 | } 26 | 27 | :host .header-metadata { 28 | color: var(--header-meta-color); 29 | text-align: right; 30 | } 31 | :host .header-metadata a { 32 | color: var(--link-font-color); 33 | text-decoration: none; 34 | } 35 | :host .header-metadata a:hover { 36 | color: var(--link-font-color-hover); 37 | } 38 | 39 | @media only screen and (max-width: 900px) { 40 | :host .header { 41 | flex-wrap: wrap; 42 | text-align: center; 43 | } 44 | :host .header-title, 45 | :host .header-metadata { 46 | width: 100%; 47 | } 48 | :host .header-metadata { 49 | padding-bottom: 12px; 50 | text-align: center; 51 | } 52 | } 53 | `; 54 | } 55 | 56 | @property({ type: Date }) generated_at = null; 57 | 58 | constructor() { 59 | super(); 60 | 61 | // Auto-refresh about once a minute so that the relative time of generation is always actual. 62 | this._refreshTimeout = setTimeout(this._refresh.bind(this), 60 * 1000); 63 | } 64 | 65 | _refresh() { 66 | this.requestUpdate(); 67 | 68 | // Continue updating. 69 | this._refreshTimeout = setTimeout(this._refresh.bind(this), 60 * 1000); 70 | } 71 | 72 | render() { 73 | let generatedAt = ""; 74 | let generatedRel = ""; 75 | 76 | if (this.generated_at) { 77 | generatedAt = greports.format.formatTimestamp(this.generated_at); 78 | 79 | let timeValue = (Date.now() - this.generated_at) / (1000 * 60); 80 | let timeUnit = "minute"; 81 | 82 | if (timeValue < 1) { 83 | generatedRel = "just now"; 84 | } else { 85 | if (timeValue > 60) { 86 | timeValue = timeValue / 60; 87 | timeUnit = "hour"; 88 | } 89 | 90 | generatedRel = greports.format.formatTimespan(-Math.round(timeValue), timeUnit); 91 | } 92 | } 93 | 94 | return html` 95 |
96 |

97 | Godot PRs by File 98 |

99 | 113 |
114 | `; 115 | } 116 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import path from 'path'; 3 | 4 | import nodeResolve from 'rollup-plugin-node-resolve'; 5 | import includePaths from 'rollup-plugin-includepaths' 6 | import commonjs from 'rollup-plugin-commonjs'; 7 | import copy from 'rollup-plugin-copy'; 8 | import posthtmlTemplate from './build/rollup-posthtml-template'; 9 | import babel from 'rollup-plugin-babel'; 10 | 11 | const INPUT_ROOT = 'src'; 12 | const INPUT_PATHS_ROOT = path.join(INPUT_ROOT, 'paths'); 13 | const INPUT_SHARED_ROOT = path.join(INPUT_ROOT, 'shared'); 14 | const INPUT_STATIC_ROOT = path.join(INPUT_ROOT, 'static'); 15 | 16 | const ENTRY_FILE_NAME = 'entry.js'; 17 | const TEMPLATE_FILE_NAME = 'template.html'; 18 | const GLOBAL_FILE_NAME = 'global.js'; 19 | 20 | const OUTPUT_ROOT = 'out'; 21 | const OUTPUT_STYLES = path.join(OUTPUT_ROOT, 'styles'); 22 | const OUTPUT_STYLES_SHARED = path.join(OUTPUT_STYLES, 'shared'); 23 | const OUTPUT_SCRIPTS = path.join(OUTPUT_ROOT, 'scripts'); 24 | const OUTPUT_SCRIPTS_SHARED = path.join(OUTPUT_SCRIPTS, 'shared'); 25 | 26 | const generateConfig = async () => { 27 | let configs = []; 28 | 29 | getGlobalConfig(configs); 30 | await getPathsConfigs(configs); 31 | 32 | return configs; 33 | }; 34 | const getGlobalConfig = (configs) => { 35 | const globalScriptPath = path.join(INPUT_SHARED_ROOT, 'scripts', GLOBAL_FILE_NAME); 36 | const outputPath = path.join(OUTPUT_SCRIPTS_SHARED, GLOBAL_FILE_NAME); 37 | 38 | const sharedStylesGlob = path.join(INPUT_SHARED_ROOT, 'styles/**/*.css').replace(/\\/g, '/'); // Windows path not supported by copy plugin 39 | const staticGlob = path.join(INPUT_STATIC_ROOT, '/**/*.*').replace(/\\/g, '/'); // Windows path not supported by copy plugin 40 | 41 | configs.push({ 42 | input: globalScriptPath, 43 | output: { 44 | name: 'global', 45 | file: outputPath, 46 | format: 'iife' 47 | }, 48 | plugins: [ 49 | nodeResolve(), 50 | copy({ 51 | targets: [ 52 | { src: sharedStylesGlob, dest: OUTPUT_STYLES_SHARED }, 53 | { src: staticGlob, dest: OUTPUT_ROOT }, 54 | ], 55 | verbose: true 56 | }) 57 | ] 58 | }) 59 | 60 | }; 61 | const getPathsConfigs = async (configs) => { 62 | try { 63 | // Collect paths to process 64 | const paths = await fs.readdir(INPUT_PATHS_ROOT); 65 | 66 | for (const itemPath of paths) { 67 | const itemRoot = path.join(INPUT_PATHS_ROOT, itemPath); 68 | const itemFiles = await fs.readdir(itemRoot); 69 | 70 | if (itemFiles.indexOf(ENTRY_FILE_NAME) < 0) { 71 | throw Error(`Missing entry script for "${itemPath}" path`); 72 | } 73 | if (itemFiles.indexOf(TEMPLATE_FILE_NAME) < 0) { 74 | throw Error(`Missing HTML template for "${itemPath}" path`); 75 | } 76 | 77 | const entryPath = path.join(itemRoot, ENTRY_FILE_NAME); 78 | const templatePath = path.join(itemRoot, TEMPLATE_FILE_NAME).replace(/\\/g, '/'); // Windows path not supported by copy plugin 79 | const bundlePath = path.join(OUTPUT_ROOT, 'scripts', `${itemPath}.js`); 80 | const htmlPath = path.join(OUTPUT_ROOT, `${itemPath}.html`); 81 | 82 | configs.push({ 83 | input: entryPath, 84 | output: { 85 | name: itemPath, 86 | file: bundlePath, 87 | format: 'iife' 88 | }, 89 | plugins: [ 90 | babel({ 91 | exclude: 'node_modules/**', 92 | plugins: [ 93 | [ '@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true } ], 94 | '@babel/plugin-proposal-class-properties' 95 | ] 96 | }), 97 | includePaths({ 98 | paths: [ './' ] 99 | }), 100 | nodeResolve(), 101 | commonjs({ 102 | sourceMap: false 103 | }), 104 | posthtmlTemplate({ 105 | src: templatePath, 106 | dest: htmlPath 107 | }) 108 | ] 109 | }) 110 | } 111 | } catch (exc) { 112 | console.error(exc); 113 | } 114 | }; 115 | 116 | export default generateConfig(); -------------------------------------------------------------------------------- /src/shared/components/SharedNavigation.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement } from 'lit-element'; 2 | 3 | @customElement('shared-nav') 4 | export default class SharedNavigation extends LitElement { 5 | static get styles() { 6 | return css` 7 | /** Colors and variables **/ 8 | :host { 9 | } 10 | @media (prefers-color-scheme: dark) { 11 | :host { 12 | } 13 | } 14 | 15 | /** Component styling **/ 16 | :host { 17 | } 18 | 19 | :host .nav-container a { 20 | color: var(--link-font-color); 21 | text-decoration: none; 22 | } 23 | :host .nav-container a:hover { 24 | color: var(--link-font-color-hover); 25 | } 26 | 27 | :host .nav-container { 28 | display: flex; 29 | gap: 8px; 30 | margin-top: 8px; 31 | background: var(--g-background-color); 32 | } 33 | 34 | :host .nav-item { 35 | font-size: 16px; 36 | font-weight: 600; 37 | padding: 10px 16px; 38 | } 39 | :host .nav-item:hover { 40 | background-color: var(--g-background-extra2-color); 41 | } 42 | 43 | :host .nav-toggler { 44 | display: none; 45 | background-image: url('hamburger.svg'); 46 | background-repeat: no-repeat; 47 | background-position: center; 48 | cursor: pointer; 49 | position: absolute; 50 | top: 0; 51 | left: 0; 52 | width: 48px; 53 | height: 48px; 54 | } 55 | :host .nav-toggler:hover { 56 | background-color: var(--g-background-extra2-color); 57 | } 58 | 59 | @media only screen and (max-width: 640px) { 60 | :host .nav-container { 61 | display: none; 62 | flex-direction: column; 63 | position: absolute; 64 | top: 0; 65 | left: 0; 66 | right: 0; 67 | padding-top: 40px; 68 | padding-bottom: 12px; 69 | } 70 | :host .nav-container.nav-active { 71 | display: flex; 72 | } 73 | 74 | :host .nav-toggler { 75 | display: block; 76 | } 77 | } 78 | `; 79 | } 80 | 81 | constructor() { 82 | super(); 83 | 84 | this._mobileActive = false; 85 | } 86 | 87 | _onMobileToggled() { 88 | this._mobileActive = !this._mobileActive; 89 | this.requestUpdate(); 90 | } 91 | 92 | render(){ 93 | const containerClassList = [ "nav-container" ]; 94 | if (this._mobileActive) { 95 | containerClassList.push("nav-active"); 96 | } 97 | 98 | return html` 99 |
100 | 101 | ClassRef Status 102 | 103 | 104 | Proposal Viewer 105 | 106 | 107 | Team Reports 108 | 109 | 110 | Commit Artifacts 111 | 112 | 113 | Interactive Changelog 114 | 115 |
116 | 120 | `; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/paths/index/components/files/FileItem.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | @customElement('gr-file-item') 4 | export default class FileItem extends LitElement { 5 | static get styles() { 6 | return css` 7 | /** Colors and variables **/ 8 | :host { 9 | --tab-hover-background-color: rgba(0, 0, 0, 0.14); 10 | --tab-active-background-color: #d6e6ff; 11 | --tab-active-border-color: #397adf; 12 | } 13 | @media (prefers-color-scheme: dark) { 14 | :host { 15 | --tab-hover-background-color: rgba(255, 255, 255, 0.14); 16 | --tab-active-background-color: #2c3c55; 17 | --tab-active-border-color: #397adf; 18 | } 19 | } 20 | 21 | /** Component styling **/ 22 | :host { 23 | max-width: 240px; 24 | } 25 | 26 | :host .file-item { 27 | border-left: 5px solid transparent; 28 | color: var(--g-font-color); 29 | cursor: pointer; 30 | display: flex; 31 | flex-direction: row; 32 | gap: 8px; 33 | padding: 3px 12px; 34 | align-items: center; 35 | } 36 | :host .file-item:hover { 37 | background-color: var(--tab-hover-background-color); 38 | } 39 | :host .file-item--active { 40 | background-color: var(--tab-active-background-color); 41 | border-left: 5px solid var(--tab-active-border-color); 42 | } 43 | 44 | :host .file-icon { 45 | background-size: 16px 16px; 46 | background-position: 50% 50%; 47 | background-repeat: no-repeat; 48 | border-radius: 2px; 49 | display: inline-block; 50 | width: 20px; 51 | height: 20px; 52 | min-width: 20px; 53 | } 54 | :host .file-icon--folder { 55 | background-image: url('folder.svg'); 56 | } 57 | :host .file-icon--file { 58 | background-image: url('file.svg'); 59 | filter: brightness(0.5); 60 | } 61 | 62 | @media (prefers-color-scheme: light) { 63 | :host .file-icon--folder { 64 | filter: brightness(0.5); 65 | } 66 | :host .file-icon--file { 67 | filter: none; 68 | } 69 | } 70 | 71 | :host .file-title { 72 | font-size: 13px; 73 | white-space: nowrap; 74 | overflow: hidden; 75 | } 76 | 77 | :host .file-pull-count { 78 | color: var(--dimmed-font-color); 79 | flex-grow: 1; 80 | font-size: 13px; 81 | text-align: right; 82 | } 83 | :host .file-pull-count--hot { 84 | color: var(--g-font-color); 85 | font-weight: 700; 86 | } 87 | 88 | @media only screen and (max-width: 900px) { 89 | :host .file-item { 90 | padding: 6px 16px; 91 | } 92 | 93 | :host .file-title, 94 | :host .file-pull-count { 95 | font-size: 16px; 96 | } 97 | } 98 | `; 99 | } 100 | 101 | @property({ type: String }) path = ""; 102 | @property({ type: String, reflect: true }) name = ""; 103 | @property({ type: String, reflect: true }) type = ""; 104 | @property({ type: Boolean, reflect: true }) active = false; 105 | @property({ type: Number }) pull_count = 0; 106 | 107 | _onIconClicked(event) { 108 | event.preventDefault(); 109 | event.stopPropagation(); 110 | this.dispatchEvent(greports.util.createEvent("iconclick"), {}); 111 | } 112 | 113 | render(){ 114 | const classList = [ "file-item" ]; 115 | if (this.active) { 116 | classList.push("file-item--active"); 117 | } 118 | 119 | const iconClassList = [ "file-icon", "file-icon--" + this.type ]; 120 | 121 | const countClassList = [ "file-pull-count" ]; 122 | if (this.pull_count > 50) { 123 | countClassList.push("file-pull-count--hot"); 124 | } 125 | 126 | return html` 127 |
131 |
135 | 136 | ${this.name} 137 | 138 | 139 | ${this.pull_count} 140 | 141 |
142 | `; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/paths/index/components/filters/PullFilter.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | const GH_PULL_URL_RE = RegExp("^https://github.com/([a-z0-9-_]+/[a-z0-9-_]+)/pull/([0-9]+)$", "i"); 4 | const GH_PULL_REF_RE = RegExp("^([a-z0-9-_]+/[a-z0-9-_]+)?#([0-9]+)$", "i"); 5 | const GH_PULL_NUMBER_RE = RegExp("^[#]?([0-9]+)$", "i"); 6 | 7 | @customElement('gr-pull-filter') 8 | export default class PullFilter extends LitElement { 9 | static get styles() { 10 | return css` 11 | /** Colors and variables **/ 12 | :host { 13 | } 14 | @media (prefers-color-scheme: dark) { 15 | :host { 16 | } 17 | } 18 | 19 | /** Component styling **/ 20 | :host { 21 | } 22 | 23 | :host .pull-filter { 24 | display: flex; 25 | gap: 12px; 26 | align-items: center; 27 | justify-content: center; 28 | margin-top: 24px; 29 | } 30 | 31 | :host .pull-filter-value { 32 | position: relative; 33 | flex-grow: 1; 34 | } 35 | 36 | :host .pull-filter-value input { 37 | background: var(--g-background-extra2-color); 38 | border: 2px solid var(--g-background-extra-color); 39 | border-radius: 4px 4px; 40 | color: var(--g-font-color); 41 | font-size: 16px; 42 | padding: 8px 48px 8px 12px; 43 | width: calc(100% - 60px); 44 | } 45 | 46 | :host .pull-filter-reset { 47 | position: absolute; 48 | right: -3px; 49 | top: 0; 50 | bottom: 0; 51 | width: 36px; 52 | background-color: var(--g-background-extra-color); 53 | background-image: url('remove.svg'); 54 | background-repeat: no-repeat; 55 | background-position: center; 56 | background-size: 20px 20px; 57 | border: 2px solid var(--g-background-extra-color); 58 | border-left: none; 59 | border-radius: 0 4px 4px 0; 60 | cursor: pointer; 61 | } 62 | :host .pull-filter-reset:hover { 63 | background-color: var(--g-background-extra2-color); 64 | } 65 | 66 | :host .pull-filter-resolved { 67 | font-weight: 600; 68 | padding: 0 8px; 69 | min-width: 60px; 70 | } 71 | 72 | @media only screen and (max-width: 900px) { 73 | :host .pull-filter { 74 | padding: 0 12px; 75 | } 76 | } 77 | `; 78 | } 79 | 80 | constructor() { 81 | super(); 82 | 83 | this._rawValue = ""; 84 | this._resolvedValue = ""; 85 | } 86 | 87 | _parsePullNumber(value) { 88 | let match = value.match(GH_PULL_URL_RE); 89 | if (match) { 90 | return match[2]; 91 | } 92 | 93 | match = value.match(GH_PULL_REF_RE); 94 | if (match) { 95 | return match[2]; 96 | } 97 | 98 | match = value.match(GH_PULL_NUMBER_RE); 99 | if (match) { 100 | return match[1]; 101 | } 102 | 103 | return "00000"; 104 | } 105 | 106 | _filterChanged(event) { 107 | this._resolvedValue = ""; 108 | this._rawValue = event.target.value.trim(); 109 | 110 | if (this._rawValue !== "") { 111 | this._resolvedValue = this._parsePullNumber(this._rawValue); 112 | } 113 | 114 | this.dispatchEvent(greports.util.createEvent("filterchanged", { 115 | "pull": this._resolvedValue, 116 | })); 117 | this.requestUpdate(); 118 | } 119 | 120 | _filterReset(event) { 121 | this._rawValue = ""; 122 | this._resolvedValue = ""; 123 | 124 | this.dispatchEvent(greports.util.createEvent("filterchanged", { 125 | "pull": this._resolvedValue, 126 | })); 127 | this.requestUpdate(); 128 | } 129 | 130 | render(){ 131 | return html` 132 |
133 | Input PR link or number: 134 |
135 | 140 |
144 |
145 | 146 | ${(this._resolvedValue !== "" ? "#" + this._resolvedValue : "")} 147 | 148 |
149 | `; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/shared/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } -------------------------------------------------------------------------------- /src/paths/index/components/pulls/PullRequestList.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | import PullRequestItem from "./PullRequestItem"; 4 | 5 | @customElement('gr-pull-list') 6 | export default class PullRequestList extends LitElement { 7 | static get styles() { 8 | return css` 9 | /** Colors and variables **/ 10 | :host { 11 | --pulls-background-color: #e5edf8; 12 | --pulls-toolbar-color: #9bbaed; 13 | --pulls-toolbar-accent-color: #5a6f90; 14 | } 15 | @media (prefers-color-scheme: dark) { 16 | :host { 17 | --pulls-background-color: #191d23; 18 | --pulls-toolbar-color: #222c3d; 19 | --pulls-toolbar-accent-color: #566783; 20 | } 21 | } 22 | 23 | /** Component styling **/ 24 | :host { 25 | flex-grow: 1; 26 | } 27 | 28 | :host input[type=checkbox] { 29 | margin: 0; 30 | vertical-align: bottom; 31 | } 32 | 33 | :host select { 34 | background: var(--pulls-background-color); 35 | border: 1px solid var(--pulls-background-color); 36 | color: var(--g-font-color); 37 | font-size: 12px; 38 | outline: none; 39 | min-width: 60px; 40 | } 41 | 42 | :host .file-pulls { 43 | background-color: var(--pulls-background-color); 44 | border-radius: 0 4px 4px 0; 45 | padding: 8px 12px; 46 | max-width: 760px; 47 | } 48 | 49 | :host .file-pulls-toolbar { 50 | background: var(--pulls-toolbar-color); 51 | border-radius: 4px; 52 | display: flex; 53 | flex-direction: column; 54 | justify-content: space-between; 55 | gap: 8px; 56 | padding: 10px 14px; 57 | margin-bottom: 6px; 58 | } 59 | 60 | :host .pulls-count { 61 | font-size: 15px; 62 | } 63 | :host .pulls-count strong { 64 | font-size: 18px; 65 | } 66 | :host .pulls-count-total { 67 | color: var(--dimmed-font-color); 68 | } 69 | 70 | :host .pulls-path { 71 | font-size: 15px; 72 | } 73 | :host .pulls-path strong { 74 | font-size: 16px; 75 | font-weight: 600; 76 | word-break: break-all; 77 | } 78 | 79 | @media only screen and (max-width: 900px) { 80 | :host .file-pulls { 81 | padding: 8px; 82 | max-width: 95%; 83 | margin: 0px auto; 84 | } 85 | 86 | :host .pulls-count { 87 | font-size: 17px; 88 | text-align: center; 89 | width: 100%; 90 | } 91 | :host .pulls-count strong { 92 | font-size: 20px; 93 | } 94 | 95 | :host .pulls-path { 96 | font-size: 17px; 97 | text-align: center; 98 | width: 100%; 99 | } 100 | :host .pulls-path strong { 101 | font-size: 18px; 102 | } 103 | } 104 | `; 105 | } 106 | 107 | @property({ type: Array }) pulls = []; 108 | @property({ type: Object }) authors = {}; 109 | 110 | @property({ type: String }) selectedRepository = ""; 111 | @property({ type: String }) selectedBranch = ""; 112 | @property({ type: String }) selectedPath = ""; 113 | @property({ type: Array }) selectedPulls = []; 114 | @property({ type: String }) filteredPull = ""; 115 | 116 | render(){ 117 | if (this.selectedPath === "") { 118 | return html``; 119 | } 120 | 121 | let pulls = [].concat(this.pulls); 122 | pulls = pulls.filter((item) => { 123 | if (item.target_branch !== this.selectedBranch) { 124 | return false; 125 | } 126 | 127 | if (!this.selectedPulls.includes(item.public_id)) { 128 | return false; 129 | } 130 | 131 | return true; 132 | }); 133 | 134 | const total_pulls = this.pulls.length; 135 | let filtered_pulls = pulls.length 136 | 137 | const has_pinned = (this.filteredPull !== ""); 138 | if (has_pinned) { 139 | filtered_pulls -= 1; 140 | } 141 | 142 | return html` 143 |
144 | ${pulls.map((item) => { 145 | if (!has_pinned || parseInt(this.filteredPull, 10) !== item.public_id) { 146 | return html``; 147 | } 148 | 149 | let author = null; 150 | if (typeof this.authors[item.authored_by] != "undefined") { 151 | author = this.authors[item.authored_by]; 152 | } 153 | 154 | return html` 155 | 172 | `; 173 | })} 174 | 175 |
176 |
177 | Current path: 178 | ./${this.selectedPath} 179 |
180 |
181 | ${(has_pinned ? "Other " : "")}PRs affecting this path: 182 | ${filtered_pulls} 183 | ${(filtered_pulls !== total_pulls) ? html` 184 | (out of ${total_pulls}) 185 | ` : '' 186 | } 187 |
188 |
189 | 190 | ${pulls.map((item) => { 191 | if (has_pinned && parseInt(this.filteredPull, 10) === item.public_id) { 192 | return html``; 193 | } 194 | 195 | let author = null; 196 | if (typeof this.authors[item.authored_by] != "undefined") { 197 | author = this.authors[item.authored_by]; 198 | } 199 | 200 | return html` 201 | 218 | `; 219 | })} 220 |
221 | `; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/paths/index/components/files/FileList.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | import RootItem from "./RootItem" 4 | import FileItem from "./FileItem"; 5 | 6 | @customElement('gr-file-list') 7 | export default class FileList extends LitElement { 8 | static get styles() { 9 | return css` 10 | /** Colors and variables **/ 11 | :host { 12 | --files-background-color: #fcfcfa; 13 | --files-border-color: #515c6c; 14 | } 15 | @media (prefers-color-scheme: dark) { 16 | :host { 17 | --files-background-color: #0d1117; 18 | --files-border-color: #515c6c; 19 | } 20 | } 21 | 22 | /** Component styling **/ 23 | :host { 24 | position: relative; 25 | } 26 | 27 | :host .file-list { 28 | background-color: var(--files-background-color); 29 | border-right: 2px solid var(--files-border-color); 30 | width: 320px; 31 | min-height: 216px; 32 | } 33 | 34 | :host .file-list-folder .file-list-folder { 35 | margin-left: 12px; 36 | } 37 | 38 | :host .branch-selector { 39 | display: none; 40 | position: absolute; 41 | top: 32px; 42 | left: 0; 43 | right: 0; 44 | flex-direction: column; 45 | gap: 4px; 46 | background-color: var(--g-background-extra2-color); 47 | border-top: 2px solid var(--g-background-color); 48 | border-bottom: 2px solid var(--g-background-color); 49 | padding: 10px 14px; 50 | } 51 | :host .branch-selector.branch-selector--active { 52 | display: flex; 53 | } 54 | 55 | :host .branch-selector ul { 56 | display: flex; 57 | flex-direction: column; 58 | align-items: flex-end; 59 | gap: 2px; 60 | list-style: none; 61 | margin: 0; 62 | padding: 0; 63 | } 64 | 65 | :host .branch-selector ul li { 66 | color: var(--link-font-color); 67 | cursor: pointer; 68 | padding: 2px 0; 69 | } 70 | :host .branch-selector ul li:hover { 71 | color: var(--link-font-color-hover); 72 | } 73 | 74 | @media only screen and (max-width: 900px) { 75 | :host { 76 | width: 100% 77 | } 78 | 79 | :host .file-list { 80 | width: 100% !important; 81 | } 82 | 83 | :host .branch-selector { 84 | border-top-width: 4px; 85 | border-bottom-width: 4px; 86 | font-size: 105%; 87 | padding: 16px 24px; 88 | top: 40px; 89 | } 90 | 91 | :host .branch-selector ul { 92 | gap: 4px; 93 | } 94 | 95 | :host .branch-selector ul li { 96 | padding: 4px 8px; 97 | } 98 | } 99 | `; 100 | } 101 | 102 | @property({ type: Array }) branches = []; 103 | @property({ type: Object }) files = {}; 104 | 105 | @property({ type: String }) selectedRepository = "godotengine/godot"; 106 | @property({ type: String }) selectedBranch = "master"; 107 | @property({ type: String }) selectedPath = ""; 108 | @property({ type: Array }) selectedFolders = []; 109 | @property({ type: String }) filteredPull = ""; 110 | 111 | constructor() { 112 | super(); 113 | 114 | this._branchSelectorActive = false; 115 | } 116 | 117 | _onBranchClicked() { 118 | this._branchSelectorActive = !this._branchSelectorActive; 119 | this.requestUpdate(); 120 | } 121 | 122 | _onBranchSelected(branchName) { 123 | this._branchSelectorActive = false; 124 | 125 | if (this.selectedBranch !== branchName) { 126 | this.selectedFolders = []; 127 | } 128 | 129 | this.requestUpdate(); 130 | 131 | this.dispatchEvent(greports.util.createEvent("branchselect", { 132 | "branch": branchName, 133 | })); 134 | } 135 | 136 | _toggleEntry(entryType, entryPath, failOnMatch) { 137 | if (entryType === "root") { 138 | this.selectedFolders = []; 139 | 140 | this.requestUpdate(); 141 | } else if (entryType === "folder") { 142 | const entryIndex = this.selectedFolders.indexOf(entryPath); 143 | if (entryIndex >= 0) { 144 | if (!failOnMatch) { 145 | this.selectedFolders.splice(entryIndex, 1); 146 | } 147 | } else { 148 | this.selectedFolders.push(entryPath); 149 | } 150 | 151 | this.requestUpdate(); 152 | } 153 | } 154 | 155 | _onItemClicked(entryType, entryPath, entryPulls) { 156 | this._toggleEntry(entryType, entryPath, true); 157 | 158 | this.dispatchEvent(greports.util.createEvent("pathclick", { 159 | "type": entryType, 160 | "path": entryPath, 161 | "pulls": entryPulls, 162 | })); 163 | } 164 | 165 | _onItemIconClicked(entryType, entryPath, entryPulls) { 166 | this._toggleEntry(entryType, entryPath, false); 167 | 168 | if (entryType === "root" || entryType === "file") { 169 | this.dispatchEvent(greports.util.createEvent("pathclick", { 170 | "type": entryType, 171 | "path": entryPath, 172 | "pulls": entryPulls, 173 | })); 174 | } 175 | } 176 | 177 | renderFolder(branchFiles, folderFiles) { 178 | return html` 179 |
180 | ${(folderFiles.length > 0) ? 181 | folderFiles.map((item) => { 182 | if (this.filteredPull !== "" && !item.pulls.includes(parseInt(this.filteredPull, 10))) { 183 | return html``; 184 | } 185 | 186 | return html` 187 |
188 | 197 | 198 | ${(this.selectedFolders.includes(item.path)) ? 199 | this.renderFolder(branchFiles, branchFiles[item.path] || []) : null 200 | } 201 |
202 | `; 203 | }) : html` 204 | This path is empty 205 | ` 206 | } 207 |
208 | `; 209 | } 210 | 211 | render() { 212 | const branchFiles = this.files[this.selectedBranch]; 213 | const topLevel = branchFiles[""] || []; 214 | 215 | const branchesClassList = [ "branch-selector" ]; 216 | if (this._branchSelectorActive) { 217 | branchesClassList.push("branch-selector--active"); 218 | } 219 | 220 | return html` 221 |
222 | 229 | 230 | ${this.renderFolder(branchFiles, topLevel)} 231 |
232 |
233 |
Available branches:
234 | 245 |
246 | `; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/paths/index/components/pulls/PullRequestItem.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | @customElement('gr-pull-request') 4 | export default class PullRequestItem extends LitElement { 5 | static get styles() { 6 | return css` 7 | /** Colors and variables **/ 8 | :host { 9 | --pr-border-color: #fcfcfa; 10 | --star-font-color: #ffcc31; 11 | --ghost-font-color: #738b99; 12 | } 13 | 14 | @media (prefers-color-scheme: dark) { 15 | :host { 16 | --pr-border-color: #0d1117; 17 | --star-font-color: #e0c537; 18 | --ghost-font-color: #495d68; 19 | } 20 | } 21 | 22 | /** Component styling **/ 23 | :host { 24 | border-bottom: 3px solid var(--pr-border-color); 25 | display: block; 26 | padding: 14px 12px 20px 12px; 27 | } 28 | 29 | :host a { 30 | color: var(--link-font-color); 31 | text-decoration: none; 32 | } 33 | :host a:hover { 34 | color: var(--link-font-color-hover); 35 | } 36 | 37 | :host .pr-title { 38 | display: inline-block; 39 | font-size: 20px; 40 | margin-top: 6px; 41 | margin-bottom: 12px; 42 | } 43 | :host .pr-title-name { 44 | color: var(--g-font-color); 45 | line-height: 24px; 46 | word-break: break-word; 47 | } 48 | 49 | :host .pr-container--draft .pr-title { 50 | filter: saturate(0.4); 51 | } 52 | :host .pr-container--draft .pr-title-name { 53 | opacity: 0.7; 54 | } 55 | 56 | :host .pr-meta { 57 | color: var(--dimmed-font-color); 58 | display: flex; 59 | flex-direction: row; 60 | justify-content: space-between; 61 | font-size: 13px; 62 | } 63 | 64 | :host .pr-milestone-value { 65 | font-weight: 700; 66 | } 67 | 68 | :host .pr-time { 69 | 70 | } 71 | :host .pr-time-value { 72 | border-bottom: 1px dashed var(--g-font-color); 73 | cursor: help; 74 | font-weight: 700; 75 | } 76 | 77 | :host .pr-author { 78 | 79 | } 80 | :host .pr-author-value { 81 | 82 | } 83 | :host .pr-author-value--hot:before { 84 | content: "★"; 85 | color: var(--star-font-color); 86 | } 87 | :host .pr-author-value--ghost { 88 | color: var(--ghost-font-color); 89 | font-weight: 600; 90 | } 91 | 92 | :host .pr-review { 93 | display: flex; 94 | justify-content: space-between; 95 | font-size: 13px; 96 | margin-top: 14px; 97 | } 98 | 99 | :host .pr-review-team { 100 | color: var(--light-font-color); 101 | white-space: nowrap; 102 | } 103 | :host .pr-review-team + .pr-review-team:before { 104 | content: "· "; 105 | white-space: break-spaces; 106 | } 107 | 108 | @media only screen and (max-width: 900px) { 109 | :host { 110 | padding: 14px 0 20px 0; 111 | } 112 | :host .pr-meta { 113 | flex-wrap: wrap; 114 | } 115 | } 116 | `; 117 | } 118 | 119 | @property({ type: String }) id = ''; 120 | @property({ type: String }) title = ''; 121 | @property({ type: String, reflect: true }) url = ''; 122 | @property({ type: String, reflect: true }) diff_url = ''; 123 | @property({ type: String, reflect: true }) patch_url = ''; 124 | 125 | @property({ type: Boolean }) draft = false; 126 | @property({ type: String, reflect: true }) milestone = ''; 127 | @property({ type: String, reflect: true }) branch = ''; 128 | 129 | @property({ type: String }) created_at = ''; 130 | @property({ type: String }) updated_at = ''; 131 | @property({ type: Object }) author = null; 132 | 133 | @property({ type: String }) repository = ''; 134 | 135 | render(){ 136 | const authorClassList = [ "pr-author-value" ]; 137 | if (this.author.pull_count > 40) { 138 | authorClassList.push("pr-author-value--hot"); 139 | } 140 | if (this.author.id === "") { 141 | authorClassList.push("pr-author-value--ghost"); 142 | } 143 | 144 | return html` 145 |
146 | 151 | #${this.id} ${this.title} 152 | 153 | 154 |
155 |
156 |
157 | milestone: 158 | ${(this.milestone != null) ? html` 159 | 163 | ${this.milestone.title} 164 | 165 | ` : html` 166 | none 167 | `} 168 |
169 |
170 | branch: 171 | 172 | ${this.branch} 173 | 174 |
175 |
176 | 177 |
178 |
179 | author: 180 | 186 | ${this.author.user} 187 | 188 |
189 |
190 | 191 |
192 |
193 | created: 194 | 198 | ${greports.format.formatDate(this.created_at)} 199 | 200 |
201 |
202 | updated: 203 | 207 | ${greports.format.formatDate(this.updated_at)} 208 | 209 |
210 |
211 |
212 | 213 |
214 |
215 | download changeset: 216 | 220 | diff 221 | | 222 | 226 | patch 227 | 228 |
229 | 230 |
231 |
232 | `; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/paths/index/entry.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property } from 'lit-element'; 2 | 3 | import PageContent from 'src/shared/components/PageContent'; 4 | import SharedNavigation from 'src/shared/components/SharedNavigation'; 5 | import IndexHeader from "./components/IndexHeader"; 6 | import IndexDescription from "./components/IndexDescription"; 7 | 8 | import PullFilter from './components/filters/PullFilter'; 9 | import FileList from "./components/files/FileList"; 10 | import PullList from "./components/pulls/PullRequestList" 11 | 12 | @customElement('entry-component') 13 | export default class EntryComponent extends LitElement { 14 | static get styles() { 15 | return css` 16 | /** Colors and variables **/ 17 | :host { 18 | } 19 | @media (prefers-color-scheme: dark) { 20 | :host { 21 | } 22 | } 23 | 24 | /** Component styling **/ 25 | :host { 26 | } 27 | 28 | :host .files { 29 | display: flex; 30 | padding: 24px 0; 31 | } 32 | 33 | @media only screen and (max-width: 900px) { 34 | :host .files { 35 | flex-wrap: wrap; 36 | } 37 | } 38 | `; 39 | } 40 | 41 | constructor() { 42 | super(); 43 | 44 | this._entryRequested = false; 45 | this._isLoading = true; 46 | this._generatedAt = null; 47 | 48 | this._authors = {}; 49 | this._branches = []; 50 | this._files = {}; 51 | this._pulls = []; 52 | 53 | this._selectedRepository = "godotengine/godot"; 54 | this._selectedBranch = "master"; 55 | this._selectedPath = ""; 56 | this._selectedPathPulls = []; 57 | 58 | this._filteredPull = ""; 59 | 60 | this._restoreUserPreferences(); 61 | this._requestData(); 62 | } 63 | 64 | performUpdate() { 65 | this._requestData(); 66 | super.performUpdate(); 67 | } 68 | 69 | _restoreUserPreferences() { 70 | const userPreferences = greports.util.getLocalPreferences(); 71 | 72 | this._selectedRepository = userPreferences["selectedRepository"]; 73 | this._restoreSelectedBranch(); 74 | } 75 | 76 | _restoreSelectedBranch() { 77 | const userPreferences = greports.util.getLocalPreferences(); 78 | 79 | if (typeof userPreferences["selectedBranches"][this._selectedRepository] !== "undefined") { 80 | this._selectedBranch = userPreferences["selectedBranches"][this._selectedRepository]; 81 | } else { 82 | this._selectedBranch = "master"; 83 | } 84 | } 85 | 86 | _saveUserPreferences() { 87 | const storedPreferences = greports.util.getLocalPreferences(); 88 | let selectedBranches = storedPreferences["selectedBranches"]; 89 | selectedBranches[this._selectedRepository] = this._selectedBranch; 90 | 91 | const currentPreferences = { 92 | "selectedRepository" : this._selectedRepository, 93 | "selectedBranches" : selectedBranches, 94 | }; 95 | 96 | greports.util.setLocalPreferences(currentPreferences); 97 | } 98 | 99 | async _requestData() { 100 | if (this._entryRequested) { 101 | return; 102 | } 103 | this._entryRequested = true; 104 | this._isLoading = true; 105 | 106 | const requested_repo = greports.util.getHistoryHash(); 107 | if (requested_repo !== "" && this._selectedRepository !== requested_repo) { 108 | this._selectedRepository = requested_repo; 109 | this._restoreSelectedBranch(); 110 | } 111 | const data = await greports.api.getData(this._selectedRepository); 112 | 113 | if (data) { 114 | this._generatedAt = data.generated_at; 115 | this._authors = data.authors; 116 | this._pulls = data.pulls; 117 | 118 | data.branches.forEach((branch) => { 119 | if (typeof data.files[branch] === "undefined") { 120 | return; 121 | } 122 | 123 | this._branches.push(branch); 124 | const branchFiles = {}; 125 | 126 | data.files[branch].forEach((file) => { 127 | if (file.type === "file" || file.type === "folder") { 128 | if (typeof branchFiles[file.parent] === "undefined") { 129 | branchFiles[file.parent] = []; 130 | } 131 | 132 | branchFiles[file.parent].push(file); 133 | } 134 | }); 135 | 136 | for (let folderName in branchFiles) { 137 | branchFiles[folderName].sort((a, b) => { 138 | if (a.type === "folder" && b.type !== "folder") { 139 | return -1; 140 | } 141 | if (b.type === "folder" && a.type !== "folder") { 142 | return 1; 143 | } 144 | 145 | const a_name = a.path.toLowerCase(); 146 | const b_name = b.path.toLowerCase(); 147 | 148 | if (a_name > b_name) return 1; 149 | if (a_name < b_name) return -1; 150 | return 0; 151 | }); 152 | } 153 | 154 | this._files[branch] = branchFiles; 155 | }); 156 | 157 | // If our preferred branch doesn't exist, pick master. 158 | if (typeof this._files[this._selectedBranch] === "undefined") { 159 | this._selectedBranch = "master"; 160 | } 161 | // If master doesn't exist, pick the first available. 162 | if (typeof this._files[this._selectedBranch] === "undefined" && data.branches.length > 0) { 163 | this._selectedBranch = data.branches[0]; 164 | } 165 | } else { 166 | this._generatedAt = null; 167 | 168 | this._authors = {}; 169 | this._branches = []; 170 | this._files = {}; 171 | this._pulls = []; 172 | 173 | this._selectedBranch = "master"; 174 | this._selectedPath = ""; 175 | this._selectedPathPulls = []; 176 | } 177 | 178 | this._isLoading = false; 179 | this.requestUpdate(); 180 | } 181 | 182 | _onPullFilterChanged(event) { 183 | this._filteredPull = event.detail.pull; 184 | if (this._filteredPull !== "") { 185 | const pullNumber = parseInt(this._filteredPull, 10); 186 | if (!this._selectedPathPulls.includes(pullNumber)) { 187 | this._selectedPath = ""; 188 | this._selectedPathPulls = []; 189 | } 190 | } 191 | 192 | this.requestUpdate(); 193 | } 194 | 195 | _onBranchSelected(event) { 196 | if (this._selectedBranch === event.detail.branch) { 197 | return; 198 | } 199 | 200 | this._selectedBranch = event.detail.branch; 201 | this._selectedPath = ""; 202 | this._selectedPathPulls = []; 203 | 204 | this._saveUserPreferences() 205 | this.requestUpdate(); 206 | } 207 | 208 | _onPathClicked(event) { 209 | this._selectedPath = event.detail.path; 210 | this._selectedPathPulls = event.detail.pulls; 211 | this.requestUpdate(); 212 | 213 | window.scrollTo(0, 0); 214 | } 215 | 216 | render(){ 217 | return html` 218 | 219 | 220 | 221 | 222 | 223 | ${(this._isLoading ? html` 224 |

Loading...

225 | ` : html` 226 | 229 | 230 |
231 | 241 | 242 | 251 |
252 | `)} 253 |
254 | `; 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /compose-db.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | const fsConstants = require('fs').constants; 3 | const fetch = require('node-fetch'); 4 | 5 | const ExitCodes = { 6 | "RequestFailure": 1, 7 | "ParseFailure": 2, 8 | }; 9 | 10 | const PULLS_PER_PAGE = 100; 11 | const API_DELAY_MSEC = 2500; 12 | const API_MAX_RETRIES = 10; 13 | const API_RATE_LIMIT = ` 14 | rateLimit { 15 | limit 16 | cost 17 | remaining 18 | resetAt 19 | } 20 | `; 21 | 22 | class DataFetcher { 23 | constructor(data_owner, data_repo) { 24 | this.api_rest_path = `https://api.github.com/repos/${data_owner}/${data_repo}`; 25 | this.api_repository_id = `owner:"${data_owner}" name:"${data_repo}"`; 26 | 27 | this.page_count = 1; 28 | this.last_cursor = ""; 29 | } 30 | 31 | async _logResponse(data, name) { 32 | try { 33 | try { 34 | await fs.access("logs", fsConstants.R_OK | fsConstants.W_OK); 35 | } catch (err) { 36 | await fs.mkdir("logs"); 37 | } 38 | 39 | await fs.writeFile(`logs/${name}.json`, JSON.stringify(data, null, 4), {encoding: "utf-8"}); 40 | } catch (err) { 41 | console.error("Error saving log file: " + err); 42 | } 43 | } 44 | 45 | _handleResponseErrors(queryID, res) { 46 | console.warn(` Failed to get data from '${queryID}'; server responded with ${res.status} ${res.statusText}`); 47 | const retry_header = res.headers.get("Retry-After"); 48 | if (retry_header) { 49 | console.log(` Retry after: ${retry_header}`); 50 | } 51 | } 52 | 53 | _handleDataErrors(data) { 54 | if (typeof data["errors"] === "undefined") { 55 | return; 56 | } 57 | 58 | console.warn(` Server handled the request, but there were errors:`); 59 | data.errors.forEach((item) => { 60 | console.log(` [${item.type}] ${item.message}`); 61 | }); 62 | } 63 | 64 | async delay(msec) { 65 | return new Promise(resolve => setTimeout(resolve, msec)); 66 | } 67 | 68 | async fetchGithub(query, retries = 0) { 69 | const init = {}; 70 | init.method = "POST"; 71 | init.headers = {}; 72 | init.headers["Content-Type"] = "application/json"; 73 | if (process.env.GRAPHQL_TOKEN) { 74 | init.headers["Authorization"] = `token ${process.env.GRAPHQL_TOKEN}`; 75 | } else if (process.env.GITHUB_TOKEN) { 76 | init.headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`; 77 | } else { 78 | console.error(" Unable to find environment variable: `GRAPHQL_TOKEN`. Did you forget to set it in your local environment or a root `.env` file?"); 79 | process.exitCode = buildCommon.ExitCodes.ParseFailure; 80 | return [null, null]; 81 | } 82 | 83 | init.body = JSON.stringify({ 84 | query, 85 | }); 86 | 87 | let res = await fetch("https://api.github.com/graphql", init); 88 | let attempt = 0; 89 | 90 | while (true) { 91 | if (attempt > retries) { 92 | return [res, null]; 93 | } 94 | 95 | if (res.status === 200) { 96 | try { 97 | const json = await res.json() 98 | return [res, json]; 99 | } 100 | catch (err) { 101 | console.log(` Failed due to invalid response body, retrying (${attempt}/${retries})...`); 102 | } 103 | } 104 | else { 105 | console.log(` Failed with status ${res.status}, retrying (${attempt}/${retries})...`); 106 | } 107 | 108 | // GitHub API is flaky, so we add an extra delay to let it calm down a bit. 109 | await this.delay(API_DELAY_MSEC); 110 | attempt += 1; 111 | res = await fetch("https://api.github.com/graphql", init); 112 | } 113 | } 114 | 115 | async fetchGithubRest(query) { 116 | const init = {}; 117 | init.method = "GET"; 118 | init.headers = {}; 119 | init.headers["Content-Type"] = "application/json"; 120 | if (process.env.GRAPHQL_TOKEN) { 121 | init.headers["Authorization"] = `token ${process.env.GRAPHQL_TOKEN}`; 122 | } else if (process.env.GITHUB_TOKEN) { 123 | init.headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`; 124 | } else { 125 | console.error(" Unable to find environment variable: `GRAPHQL_TOKEN`. Did you forget to set it in your local environment or a root `.env` file?"); 126 | process.exitCode = buildCommon.ExitCodes.ParseFailure; 127 | return null; 128 | } 129 | 130 | return await fetch(`${this.api_rest_path}${query}`, init); 131 | } 132 | 133 | async checkRates() { 134 | try { 135 | const query = ` 136 | query { 137 | ${API_RATE_LIMIT} 138 | } 139 | `; 140 | 141 | const [res, data] = await this.fetchGithub(query); 142 | if (res.status !== 200 || data == null) { 143 | this._handleResponseErrors(this.api_repository_id, res); 144 | process.exitCode = ExitCodes.RequestFailure; 145 | return; 146 | } 147 | 148 | await this._logResponse(data, "_rate_limit"); 149 | this._handleDataErrors(data); 150 | 151 | const rate_limit = data.data["rateLimit"]; 152 | console.log(` [$${rate_limit.cost}] Available API calls: ${rate_limit.remaining}/${rate_limit.limit}; resets at ${rate_limit.resetAt}`); 153 | } catch (err) { 154 | console.error(" Error checking the API rate limits: " + err); 155 | process.exitCode = ExitCodes.RequestFailure; 156 | return; 157 | } 158 | } 159 | 160 | async fetchPulls(page) { 161 | try { 162 | let after_cursor = ""; 163 | let after_text = "initial"; 164 | if (this.last_cursor !== "") { 165 | after_cursor = `after: "${this.last_cursor}"`; 166 | after_text = after_cursor; 167 | } 168 | 169 | const query = ` 170 | query { 171 | ${API_RATE_LIMIT} 172 | repository(${this.api_repository_id}) { 173 | pullRequests(first:${PULLS_PER_PAGE} ${after_cursor} states: OPEN) { 174 | totalCount 175 | pageInfo { 176 | endCursor 177 | hasNextPage 178 | } 179 | edges { 180 | node { 181 | id 182 | number 183 | url 184 | title 185 | state 186 | isDraft 187 | 188 | createdAt 189 | updatedAt 190 | 191 | baseRef { 192 | name 193 | } 194 | 195 | author { 196 | login 197 | avatarUrl 198 | url 199 | 200 | ... on User { 201 | id 202 | } 203 | } 204 | 205 | milestone { 206 | id 207 | title 208 | url 209 | } 210 | 211 | files (first: 100) { 212 | edges { 213 | node { 214 | path 215 | changeType 216 | } 217 | } 218 | } 219 | } 220 | } 221 | } 222 | } 223 | } 224 | `; 225 | 226 | let page_text = page; 227 | if (this.page_count > 1) { 228 | page_text = `${page}/${this.page_count}`; 229 | } 230 | console.log(` Requesting page ${page_text} of pull request data (${after_text}).`); 231 | 232 | const [res, data] = await this.fetchGithub(query, API_MAX_RETRIES); 233 | if (res.status !== 200 || data == null) { 234 | this._handleResponseErrors(this.api_repository_id, res); 235 | process.exitCode = ExitCodes.RequestFailure; 236 | return []; 237 | } 238 | 239 | await this._logResponse(data, `data_page_${page}`); 240 | this._handleDataErrors(data); 241 | 242 | const rate_limit = data.data["rateLimit"]; 243 | const repository = data.data["repository"]; 244 | const pulls_data = mapNodes(repository.pullRequests); 245 | 246 | console.log(` [$${rate_limit.cost}] Retrieved ${pulls_data.length} pull requests; processing...`); 247 | 248 | this.last_cursor = repository.pullRequests.pageInfo.endCursor; 249 | this.page_count = Math.ceil(repository.pullRequests.totalCount / PULLS_PER_PAGE); 250 | 251 | return pulls_data; 252 | } catch (err) { 253 | console.error(" Error fetching pull request data: " + err); 254 | process.exitCode = ExitCodes.RequestFailure; 255 | return []; 256 | } 257 | } 258 | 259 | async fetchFiles(branch) { 260 | try { 261 | const query = `/git/trees/${branch}?recursive=1`; 262 | 263 | const res = await this.fetchGithubRest(query); 264 | if (res.status !== 200) { 265 | this._handleResponseErrors(query, res); 266 | process.exitCode = ExitCodes.RequestFailure; 267 | return []; 268 | } 269 | 270 | const data = await res.json(); 271 | await this._logResponse(data, `data_files_${branch}`); 272 | this._handleDataErrors(data); 273 | 274 | const files_data = data.tree; 275 | 276 | console.log(` [$0] Retrieved ${files_data.length} file system entries in '${branch}'; processing...`); 277 | 278 | return files_data; 279 | } catch (err) { 280 | console.error(" Error fetching pull request data: " + err); 281 | process.exitCode = ExitCodes.RequestFailure; 282 | return []; 283 | } 284 | } 285 | } 286 | 287 | class DataProcessor { 288 | constructor() { 289 | this.authors = {}; 290 | this.pulls = []; 291 | this.branches = []; 292 | this.files = {}; 293 | 294 | this._pullsByFile = {}; 295 | } 296 | 297 | _explainFileType(type) { 298 | switch(type) { 299 | case "blob": 300 | return "file"; 301 | case "tree": 302 | return "folder"; 303 | default: 304 | return "unknown"; 305 | } 306 | } 307 | 308 | processPulls(pullsRaw) { 309 | try { 310 | pullsRaw.forEach((item) => { 311 | // Compile basic information about a PR. 312 | let pr = { 313 | "id": item.id, 314 | "public_id": item.number, 315 | "url": item.url, 316 | "diff_url": `${item.url}.diff`, 317 | "patch_url": `${item.url}.patch`, 318 | 319 | "title": item.title, 320 | "state": item.state, 321 | "is_draft": item.isDraft, 322 | "authored_by": null, 323 | "created_at": item.createdAt, 324 | "updated_at": item.updatedAt, 325 | 326 | "target_branch": item.baseRef.name, 327 | "milestone": null, 328 | 329 | "files": [], 330 | }; 331 | 332 | // Store the target branch if it hasn't been stored. 333 | if (!this.branches.includes(pr.target_branch)) { 334 | this.branches.push(pr.target_branch); 335 | } 336 | 337 | // Compose and link author information. 338 | const author = { 339 | "id": "", 340 | "user": "ghost", 341 | "avatar": "https://avatars.githubusercontent.com/u/10137?v=4", 342 | "url": "https://github.com/ghost", 343 | "pull_count": 0, 344 | }; 345 | if (item.author != null) { 346 | author["id"] = item.author.id; 347 | author["user"] = item.author.login; 348 | author["avatar"] = item.author.avatarUrl; 349 | author["url"] = item.author.url; 350 | } 351 | pr.authored_by = author.id; 352 | 353 | // Store the author if they haven't been stored. 354 | if (typeof this.authors[author.id] === "undefined") { 355 | this.authors[author.id] = author; 356 | } 357 | this.authors[author.id].pull_count++; 358 | 359 | // Add the milestone, if available. 360 | if (item.milestone) { 361 | pr.milestone = { 362 | "id": item.milestone.id, 363 | "title": item.milestone.title, 364 | "url": item.milestone.url, 365 | }; 366 | } 367 | 368 | // Add changed files. 369 | let files = mapNodes(item.files); 370 | const visitedPaths = []; 371 | 372 | if (typeof this._pullsByFile[pr.target_branch] === "undefined") { 373 | this._pullsByFile[pr.target_branch] = {}; 374 | } 375 | 376 | files.forEach((fileItem) => { 377 | let currentPath = fileItem.path; 378 | while (currentPath !== "") { 379 | if (visitedPaths.includes(currentPath)) { 380 | // Go one level up. 381 | const pathBits = currentPath.split("/"); 382 | pathBits.pop(); 383 | currentPath = pathBits.join("/"); 384 | 385 | continue; 386 | } 387 | visitedPaths.push(currentPath); 388 | 389 | pr.files.push({ 390 | "path": currentPath, 391 | "changeType": (currentPath === fileItem.path ? fileItem.changeType : ""), 392 | "type": (currentPath === fileItem.path ? "file" : "folder"), 393 | }); 394 | 395 | // Cache the pull information for every file and folder that it includes. 396 | if (typeof this._pullsByFile[pr.target_branch][currentPath] === "undefined") { 397 | this._pullsByFile[pr.target_branch][currentPath] = []; 398 | } 399 | this._pullsByFile[pr.target_branch][currentPath].push(pr.public_id); 400 | 401 | // Go one level up. 402 | const pathBits = currentPath.split("/"); 403 | pathBits.pop(); 404 | currentPath = pathBits.join("/"); 405 | } 406 | }); 407 | pr.files.sort((a, b) => { 408 | if (a.name > b.name) return 1; 409 | if (a.name < b.name) return -1; 410 | return 0; 411 | }); 412 | 413 | this.pulls.push(pr); 414 | }); 415 | } catch (err) { 416 | console.error(" Error parsing pull request data: " + err); 417 | process.exitCode = ExitCodes.ParseFailure; 418 | } 419 | } 420 | 421 | processFiles(targetBranch, filesRaw) { 422 | try { 423 | this.files[targetBranch] = []; 424 | 425 | filesRaw.forEach((item) => { 426 | let file = { 427 | "type": this._explainFileType(item.type), 428 | "name": item.path.split("/").pop(), 429 | "path": item.path, 430 | "parent": "", 431 | "pulls": [], 432 | }; 433 | 434 | // Store the parent path for future reference. 435 | let parentPath = item.path.split("/"); 436 | parentPath.pop(); 437 | if (parentPath.length > 0) { 438 | file.parent = parentPath.join("/"); 439 | } 440 | 441 | // Fetch the PRs touching this file or folder from the cache. 442 | if (typeof this._pullsByFile[targetBranch] !== "undefined" 443 | && typeof this._pullsByFile[targetBranch][file.path] !== "undefined") { 444 | 445 | this._pullsByFile[targetBranch][file.path].forEach((pullNumber) => { 446 | if (!file.pulls.includes(pullNumber)) { 447 | file.pulls.push(pullNumber); 448 | } 449 | }); 450 | } 451 | 452 | this.files[targetBranch].push(file); 453 | }); 454 | } catch (err) { 455 | console.error(" Error parsing repository file system: " + err); 456 | process.exitCode = ExitCodes.ParseFailure; 457 | } 458 | } 459 | } 460 | 461 | function mapNodes(object) { 462 | return object.edges.map((item) => item["node"]) 463 | } 464 | 465 | async function main() { 466 | // Internal utility methods. 467 | const checkForExit = () => { 468 | if (process.exitCode > 0) { 469 | process.exit(); 470 | } 471 | } 472 | const delay = async (msec) => { 473 | return new Promise(resolve => setTimeout(resolve, msec)); 474 | } 475 | 476 | console.log("[*] Building local pull request database."); 477 | 478 | let data_owner = "godotengine"; 479 | let data_repo = "godot"; 480 | process.argv.forEach((arg) => { 481 | if (arg.indexOf("owner:") === 0) { 482 | data_owner = arg.substring(6); 483 | } 484 | if (arg.indexOf("repo:") === 0) { 485 | data_repo = arg.substring(5); 486 | } 487 | }); 488 | 489 | console.log(`[*] Configured for the "${data_owner}/${data_repo}" repository.`); 490 | const dataFetcher = new DataFetcher(data_owner, data_repo); 491 | const dataProcessor = new DataProcessor(); 492 | 493 | console.log("[*] Checking the rate limits before."); 494 | await dataFetcher.checkRates(); 495 | checkForExit(); 496 | 497 | console.log("[*] Fetching pull request data from GitHub."); 498 | // Pages are starting with 1 for better presentation. 499 | let page = 1; 500 | while (page <= dataFetcher.page_count) { 501 | const pullsRaw = await dataFetcher.fetchPulls(page); 502 | dataProcessor.processPulls(pullsRaw); 503 | checkForExit(); 504 | page++; 505 | 506 | // Wait for a bit before proceeding to avoid hitting the secondary rate limit in GitHub API. 507 | // See https://docs.github.com/en/rest/guides/best-practices-for-integrators#dealing-with-secondary-rate-limits. 508 | await delay(1500); 509 | } 510 | 511 | console.log("[*] Fetching repository file system from GitHub."); 512 | for (let branch of dataProcessor.branches) { 513 | const filesRaw = await dataFetcher.fetchFiles(branch); 514 | dataProcessor.processFiles(branch, filesRaw); 515 | checkForExit(); 516 | } 517 | 518 | console.log("[*] Checking the rate limits after.") 519 | await dataFetcher.checkRates(); 520 | checkForExit(); 521 | 522 | console.log("[*] Finalizing database.") 523 | const output = { 524 | "generated_at": Date.now(), 525 | "authors": dataProcessor.authors, 526 | "pulls": dataProcessor.pulls, 527 | "branches": dataProcessor.branches, 528 | "files": dataProcessor.files, 529 | }; 530 | try { 531 | console.log("[*] Storing database to file."); 532 | await fs.writeFile(`out/${data_owner}.${data_repo}.data.json`, JSON.stringify(output), {encoding: "utf-8"}); 533 | console.log("[*] Database built."); 534 | } catch (err) { 535 | console.error("Error saving database file: " + err); 536 | } 537 | } 538 | 539 | main(); 540 | --------------------------------------------------------------------------------