diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ab3ce23107..42ef157516 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,19 +7,23 @@ FAILURE TO COMPLETE ALL REQUIREMENTS WILL RESULT IN A DENIAL OF YOUR PR! -- [ ] I **agree** to the [Terms of Service](https://is-a.dev/terms). -- [ ] My file is following the [domain structure](https://docs.is-a.dev/domain-structure/). -- [ ] My website is **reachable** and **completed**. -- [ ] My website is **software development** related. -- [ ] My website is **not for commercial use**. -- [ ] I have provided sufficient contact information in the `owner` key. -- [ ] I have provided a link to my website below. +- [ ] I **agree** to the [Terms of Service](https://is-a.dev/terms). +- [ ] My file is following the [domain structure](https://docs.is-a.dev/domain-structure/). +- [ ] My website is **reachable** and **completed**. +- [ ] My website is **software development** related. +- [ ] My website is **not for commercial use**. +- [ ] I have provided sufficient contact information in the `owner` key. +- [ ] I have provided a link to my website below. # Website Preview - + - - + + + # Website Purpose + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 826f192592..84b74fed9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,20 +29,15 @@ jobs: - name: Fetch PR information if: github.event_name == 'pull_request' - run: | - echo "PR_AUTHOR=${{ github.event.pull_request.user.login }}" >> $GITHUB_ENV - echo "PR_AUTHOR_ID=${{ github.event.pull_request.user.id }}" >> $GITHUB_ENV - - LABELS=$(gh api --jq '[.labels[].name]' /repos/{owner}/{repo}/pulls/${{ github.event.number }}) - echo "PR_LABELS=$LABELS" >> $GITHUB_ENV - - FILES=$(gh api --jq '[.[] | select(.status != "removed") | .filename]' /repos/{owner}/{repo}/pulls/${{ github.event.number }}/files) - echo "CHANGED_FILES=$FILES" >> $GITHUB_ENV - - REMOVED_FILE_DATA=$(gh api --jq '[.[] | select(.status == "removed") | {name: .filename, data: .patch}]' /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files) - echo "DELETED_FILES=$REMOVED_FILE_DATA" >> $GITHUB_ENV env: GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + echo "PR_AUTHOR=${{ github.event.pull_request.user.login }}" >> "$GITHUB_ENV" + echo "PR_AUTHOR_ID=${{ github.event.pull_request.user.id }}" >> "$GITHUB_ENV" + echo "PR_LABELS=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '[.labels[].name]')" >> "$GITHUB_ENV" + echo "CHANGED_FILES=$(gh api --jq '[.[] | select(.status != "removed") | .filename]' "repos/${{ github.repository }}/pulls/$PR/files")" >> "$GITHUB_ENV" + echo "DELETED_FILES=$(gh api --jq '[.[] | select(.status == "removed") | {name: .filename, data: .patch}]' "repos/${{ github.repository }}/pulls/$PR/files")" >> "$GITHUB_ENV" - uses: gacts/install-dnscontrol@890ba3249712c51203ccb7aba63307c0091e921b # v1.3.4 if: github.event_name == 'pull_request' && contains(fromJson(env.CHANGED_FILES), 'dnsconfig.js') @@ -50,8 +45,8 @@ jobs: version: 4.14.0 - name: Run DNSControl checks - run: dnscontrol check if: github.event_name == 'pull_request' && contains(fromJson(env.CHANGED_FILES), 'dnsconfig.js') + run: dnscontrol check - name: Run tests run: npx ava tests/*.test.js --timeout=1m diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000000..06539107bb --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,262 @@ +name: PR + +on: + pull_request_target: + types: + - opened + - reopened + - edited + - synchronize + - labeled + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + label: + name: Label + runs-on: ubuntu-latest + + steps: + - name: Check changed paths + id: changes + uses: dorny/paths-filter@v3 + with: + filters: | + ci: + - ".github/workflows/*" + - "tests/*" + - "util/*" + - "package.json" + - "package-lock.json" + - "dnsconfig.js" + domain: + - "domains/*" + documentation: + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/copilot-instructions.md" + - "README.md" + r-william: + - ".github/CODEOWNERS" + - "CODE_OF_CONDUCT.md" + - "CONTRIBUTING.md" + - "SECURITY.md" + - "TERMS_OF_SERVICE.md" + - "LICENSE" + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Check trusted user + id: check-trusted + continue-on-error: true + env: + PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }} + run: | + node - <<'NODE' + const trustedUsers = require("./util/trusted.json"); + + const authorId = Number(process.env.PR_AUTHOR_ID); + + if (!trustedUsers.some((u) => u.id === authorId)) { + console.log("PR author is not a trusted user."); + process.exit(1); + } + + console.log("PR author is a trusted user."); + NODE + + - name: Add labels + uses: actions/github-script@v8 + env: + CI_CHANGED: ${{ steps.changes.outputs.ci }} + DOCUMENTATION: ${{ steps.changes.outputs.documentation }} + DOMAIN_CHANGED: ${{ steps.changes.outputs.domain }} + MAINTAINER: ${{ steps.check-trusted.outcome == 'success' }} + R_WILLIAM: ${{ steps.changes.outputs.r-william }} + with: + github-token: ${{ secrets.BOT }} + script: | + const labels = []; + + if (process.env.CI_CHANGED === "true") { + labels.push("ci"); + labels.push("r: william"); + } + if (process.env.DOCUMENTATION === "true") labels.push("documentation"); + if (process.env.DOMAIN_CHANGED === "true") labels.push("domain"); + if (process.env.MAINTAINER === "true") labels.push("maintainer"); + if (process.env.R_WILLIAM === "true") labels.push("r: william"); + + const uniqueLabels = [...new Set(labels)]; + + if (uniqueLabels.length) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: uniqueLabels + }); + } + + template: + name: Template + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Validate PR template + id: validate + continue-on-error: true + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: node util/check-pr-template.cjs + + - name: Handle incomplete PR + if: steps.validate.outcome == 'failure' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.BOT }} + script: | + const label = "reason: incomplete pr"; + + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label + }); + } catch (error) { + if (error.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: "D93F0B", + description: "Your PR template is not complete. (e.g. missing the requirements form, missing website preview)" + }); + } else { + throw error; + } + } + + const labels = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + if (!labels.data.some((item) => item.name === label)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [label] + }); + } + + const marker = ""; + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + } + ); + + const alreadyCommented = comments.some( + (comment) => + comment.user?.login === "is-a-dev-bot" && + comment.body?.includes(marker) + ); + + if (!alreadyCommented) { + const body = [ + marker, + "## ❌ Incomplete PR template", + "", + "This pull request is missing required information from the PR template.", + "", + "***Your PR will not be approved without the template being properly filled out!***", + "", + "Please make sure that:", + "", + "- The PR template has not been modified.", + " - *Modifying the PR template will make this check fail.*", + "- All requirement checkboxes are checked.", + " - *All checkboxes must be changed exactly to `[x]`, NOT something like `[ x ]`.*", + "- **Website Preview** is filled out.", + " - *Make sure it is filled between the start/end comment markers.*", + "- **Website Purpose** is filled out.", + " - *Make sure it is filled between the start/end comment markers.*", + "---", + "Once the template has been properly completed, this check will run again automatically." + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + } + + - name: Remove incomplete label and comment + if: steps.validate.outcome == 'success' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.BOT }} + script: | + const label = "reason: incomplete pr"; + const marker = ""; + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: label + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + } + ); + + for (const comment of comments) { + if ( + comment.user?.login === "is-a-dev-bot" && + comment.body?.includes(marker) + ) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id + }); + } + } + + - name: Fail if PR template is incomplete + if: steps.validate.outcome == 'failure' + run: | + echo "::error::PR template is incomplete." + exit 1 diff --git a/.github/workflows/update-pr-branches.yml b/.github/workflows/update-pr-branches.yml new file mode 100644 index 0000000000..6c68c989e5 --- /dev/null +++ b/.github/workflows/update-pr-branches.yml @@ -0,0 +1,41 @@ +name: Update PR Branches + +on: + workflow_dispatch: + +jobs: + update: + name: Update + runs-on: ubuntu-latest + + steps: + - name: Update branches + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.BOT }} + script: | + const prs = await github.paginate( + github.rest.pulls.list, + { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100 + } + ); + + console.log(`Found ${prs.length} open PRs`); + + for (const pr of prs) { + try { + await github.rest.pulls.updateBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + console.log(`Updated #${pr.number} (${pr.head.ref})`); + } catch (error) { + console.log(`Could not update #${pr.number}: ${error.message}`); + } + } diff --git a/domains/db-projetos.adssousag.json b/domains/db-projetos.adssousag.json index 922dcdfd2e..964e2a8b88 100644 --- a/domains/db-projetos.adssousag.json +++ b/domains/db-projetos.adssousag.json @@ -4,7 +4,7 @@ "email": "arthursantossousa05@gmail.com" }, "records": { - "CNAME": "p3-host.triniumhost.com" + "CNAME": "p-host.triniumhost.indevs.in" }, "proxied": true } diff --git a/domains/kiritoapi.adssousag.json b/domains/kiritoapi.adssousag.json index de8e5bd978..964e2a8b88 100644 --- a/domains/kiritoapi.adssousag.json +++ b/domains/kiritoapi.adssousag.json @@ -4,7 +4,7 @@ "email": "arthursantossousa05@gmail.com" }, "records": { - "CNAME": "kiritoapi-host.triniumhost.com" + "CNAME": "p-host.triniumhost.indevs.in" }, "proxied": true } diff --git a/domains/kiritodb.adssousag.json b/domains/kiritodb.adssousag.json index 42a2f78e7a..964e2a8b88 100644 --- a/domains/kiritodb.adssousag.json +++ b/domains/kiritodb.adssousag.json @@ -4,7 +4,7 @@ "email": "arthursantossousa05@gmail.com" }, "records": { - "CNAME": "kiritodb-host.triniumhost.com" + "CNAME": "p-host.triniumhost.indevs.in" }, "proxied": true } diff --git a/domains/mesh-kiritoapi.adssousag.json b/domains/mesh-kiritoapi.adssousag.json index 922dcdfd2e..964e2a8b88 100644 --- a/domains/mesh-kiritoapi.adssousag.json +++ b/domains/mesh-kiritoapi.adssousag.json @@ -4,7 +4,7 @@ "email": "arthursantossousa05@gmail.com" }, "records": { - "CNAME": "p3-host.triniumhost.com" + "CNAME": "p-host.triniumhost.indevs.in" }, "proxied": true } diff --git a/domains/myst-kiritoapi.adssousag.json b/domains/myst-kiritoapi.adssousag.json index 922dcdfd2e..964e2a8b88 100644 --- a/domains/myst-kiritoapi.adssousag.json +++ b/domains/myst-kiritoapi.adssousag.json @@ -4,7 +4,7 @@ "email": "arthursantossousa05@gmail.com" }, "records": { - "CNAME": "p3-host.triniumhost.com" + "CNAME": "p-host.triniumhost.indevs.in" }, "proxied": true } diff --git a/domains/status-kiritoapi.adssousag.json b/domains/status-kiritoapi.adssousag.json index 922dcdfd2e..964e2a8b88 100644 --- a/domains/status-kiritoapi.adssousag.json +++ b/domains/status-kiritoapi.adssousag.json @@ -4,7 +4,7 @@ "email": "arthursantossousa05@gmail.com" }, "records": { - "CNAME": "p3-host.triniumhost.com" + "CNAME": "p-host.triniumhost.indevs.in" }, "proxied": true } diff --git a/domains/status-kiritodb.adssousag.json b/domains/status-kiritodb.adssousag.json index 922dcdfd2e..964e2a8b88 100644 --- a/domains/status-kiritodb.adssousag.json +++ b/domains/status-kiritodb.adssousag.json @@ -4,7 +4,7 @@ "email": "arthursantossousa05@gmail.com" }, "records": { - "CNAME": "p3-host.triniumhost.com" + "CNAME": "p-host.triniumhost.indevs.in" }, "proxied": true } diff --git a/util/check-pr-template.cjs b/util/check-pr-template.cjs new file mode 100644 index 0000000000..51a8b8ec23 --- /dev/null +++ b/util/check-pr-template.cjs @@ -0,0 +1,97 @@ +const trustedUsers = require("./trusted.json"); + +const body = process.env.PR_BODY || ""; +const authorUsername = process.env.PR_AUTHOR || ""; +const authorId = Number(process.env.PR_AUTHOR_ID); +const labels = JSON.parse(process.env.PR_LABELS || "[]"); + +const BYPASS_LABELS = ["ci: bypass-template-check", "maintainer"]; + +const isTrustedUser = trustedUsers.some((u) => u.id === authorId); +const hasBypassLabel = labels.some((l) => BYPASS_LABELS.includes(l)); + +if (isTrustedUser) { + console.log(`PR author "${authorUsername}" is a trusted user. Skipping template validation.`); + process.exit(0); +} + +if (hasBypassLabel) { + console.log(`PR has a label which bypasses this check. Skipping template validation.`); + process.exit(0); +} + +const REQUIRED_CHECKBOXES = [ + "TOS", + "DOMAIN_STRUCTURE", + "WEBSITE_REACHABLE", + "SOFTWARE_RELATED", + "NON_COMMERCIAL", + "CONTACT_INFO", + "WEBSITE_LINK", +]; + +function isCheckboxChecked(marker) { + const regex = new RegExp( + `-\\s*\\[[xX]\\]\\s*`, + "i", + ); + + return regex.test(body); +} + +function getSection(startMarker, endMarker) { + const regex = new RegExp( + `${escapeRegExp(startMarker)}([\\s\\S]*?)${escapeRegExp(endMarker)}`, + "i", + ); + + const match = body.match(regex); + + return match ? match[1].trim() : ""; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const errors = []; + +for (const checkbox of REQUIRED_CHECKBOXES) { + if (!isCheckboxChecked(checkbox)) { + errors.push( + `The \`${checkbox}\` requirement has not been checked.`, + ); + } +} + +const websitePreview = getSection( + "", + "", +); + +if (!websitePreview) { + errors.push( + "The **Website Preview** section has not been filled out.", + ); +} + +const websitePurpose = getSection( + "", + "", +); + +if (!websitePurpose) { + errors.push( + "The **Website Purpose** section has not been filled out.", + ); +} + +if (errors.length > 0) { + console.error("PR template validation failed."); + console.error(""); + console.error(errors.join("\n")); + + process.exit(1); +} + +console.log("PR template validation passed.");