aboutsummaryrefslogtreecommitdiff
path: root/.github/workflows
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 02:13:30 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 02:14:52 +0300
commit472e2b8453160affd05760b22a483b3c9ee100be (patch)
tree3d72f0778c3a3e9f2bc0091d9f07fa4e526c0858 /.github/workflows
parent8307f2f712293970d80f624594d51e693d711cfb (diff)
chore: route feature intake to discussions
Diffstat (limited to '.github/workflows')
-rw-r--r--.github/workflows/approve-contributor.yml27
-rw-r--r--.github/workflows/issue-gate.yml144
-rw-r--r--.github/workflows/label-intends-to-pr.yml92
-rw-r--r--.github/workflows/pr-gate.yml17
4 files changed, 171 insertions, 109 deletions
diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml
index 0f66017d..069ef898 100644
--- a/.github/workflows/approve-contributor.yml
+++ b/.github/workflows/approve-contributor.yml
@@ -29,13 +29,18 @@ jobs:
const commenter = context.payload.comment.user.login;
const commentBody = context.payload.comment.body || '';
const approvedFile = '.github/APPROVED_CONTRIBUTORS';
+ const approveMatch = commentBody.match(/(^|\s)\/approve(?:\s+@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?))?(?=$|\s)/i);
+ const maintainerPermissions = ['admin', 'maintain', 'write'];
- if (!/(^|\s)\/approve(?=$|\s)/i.test(commentBody)) {
+ if (!approveMatch) {
console.log('Comment does not contain /approve');
core.setOutput('status', 'skipped');
return;
}
+ const approvedUser = approveMatch[2] || issueAuthor;
+ core.setOutput('approved_user', approvedUser);
+
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
@@ -43,8 +48,8 @@ jobs:
username: commenter,
});
- if (permissionLevel.permission !== 'admin') {
- console.log(`${commenter} does not have admin access`);
+ if (!maintainerPermissions.includes(permissionLevel.permission)) {
+ console.log(`${commenter} does not have maintainer access`);
core.setOutput('status', 'skipped');
return;
}
@@ -60,22 +65,22 @@ jobs:
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#'));
- if (approvedList.includes(issueAuthor.toLowerCase())) {
- console.log(`${issueAuthor} is already approved`);
+ if (approvedList.includes(approvedUser.toLowerCase())) {
+ console.log(`${approvedUser} is already approved`);
core.setOutput('status', 'already');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
- body: `@${issueAuthor} is already in the approved contributors list.`,
+ body: `@${approvedUser} is already in the approved contributors list.`,
});
return;
}
- content = content.trimEnd() + '\n' + issueAuthor + '\n';
+ content = content.trimEnd() + '\n' + approvedUser + '\n';
fs.writeFileSync(approvedFile, content);
- console.log(`Added ${issueAuthor} to approved contributors`);
+ console.log(`Added ${approvedUser} to approved contributors`);
core.setOutput('status', 'added');
- name: Commit and push
@@ -86,7 +91,7 @@ jobs:
git config user.name "kangal-bot"
git config user.email "285672167+kangal-bot@users.noreply.github.com"
git add .github/APPROVED_CONTRIBUTORS
- git diff --staged --quiet || git commit -m "chore: approve contributor ${{ github.event.issue.user.login }}"
+ git diff --staged --quiet || git commit -m "chore: approve contributor ${{ steps.update.outputs.approved_user }}"
git push "https://x-access-token:${KANGAL_GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:${{ github.event.repository.default_branch }}
- name: Comment on issue
@@ -95,10 +100,10 @@ jobs:
with:
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
- const issueAuthor = context.payload.issue.user.login;
+ const approvedUser = '${{ steps.update.outputs.approved_user }}';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
- body: `@${issueAuthor} has been added to the approved contributors list. You can now submit PRs. Thanks for contributing!`,
+ body: `@${approvedUser} has been added to the approved contributors list. You can now submit PRs for maintainer-approved work. Thanks for contributing!`,
});
diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml
new file mode 100644
index 00000000..52f6a150
--- /dev/null
+++ b/.github/workflows/issue-gate.yml
@@ -0,0 +1,144 @@
+name: Issue Gate
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ issues: write
+
+jobs:
+ check-template:
+ if: ${{ !github.event.issue.pull_request }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Close non-template issues
+ uses: actions/github-script@v9
+ with:
+ github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
+ script: |
+ const issue = context.payload.issue;
+ const author = issue.user.login;
+ const sender = context.payload.sender?.login ?? author;
+ const bugConfirmationPattern = /^\s*-\s*\[[xX]\]\s*I confirm this is a reproducible bug, not a feature request, idea, question, contribution proposal, or direction check\.\s*$/m;
+ const requiredSections = [
+ '### Is this a reproducible bug?',
+ '### Current behavior',
+ '### Expected behavior',
+ '### Reproduction',
+ '### Impact',
+ '### Environment',
+ ];
+ const requiredEnvironmentFields = [
+ /^\s*-\s*Herdr version:\s*\S.*$/m,
+ /^\s*-\s*Operating system:\s*\S.*$/m,
+ /^\s*-\s*Terminal:\s*\S.*$/m,
+ ];
+
+ function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ function extractSection(body, heading) {
+ const pattern = new RegExp(`^${escapeRegExp(heading)}\\s*$`, 'm');
+ const match = pattern.exec(body);
+ if (!match) {
+ return null;
+ }
+
+ const sectionStart = match.index + match[0].length;
+ const rest = body.slice(sectionStart);
+ const headingIndex = requiredSections.indexOf(heading);
+ const nextTemplateHeading = requiredSections
+ .slice(headingIndex + 1)
+ .map((section) => {
+ const nextPattern = new RegExp(`^${escapeRegExp(section)}\\s*$`, 'm');
+ const nextMatch = nextPattern.exec(rest);
+ return nextMatch ? nextMatch.index : -1;
+ })
+ .filter((index) => index !== -1)
+ .sort((left, right) => left - right)[0];
+ return (nextTemplateHeading === undefined ? rest : rest.slice(0, nextTemplateHeading)).trim();
+ }
+
+ function hasContent(section) {
+ return typeof section === 'string' && section.trim().length > 0;
+ }
+
+ async function getPermission(username) {
+ try {
+ const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ username,
+ });
+ return permissionLevel.permission;
+ } catch {
+ return null;
+ }
+ }
+
+ const maintainerPermissions = ['admin', 'maintain', 'write'];
+ const authorPermission = await getPermission(author);
+ if (maintainerPermissions.includes(authorPermission)) {
+ console.log(`${author} is a collaborator with ${authorPermission} access; leaving issue open`);
+ return;
+ }
+
+ const senderPermission = sender === author ? authorPermission : await getPermission(sender);
+ if (maintainerPermissions.includes(senderPermission)) {
+ console.log(`${sender} opened this issue with ${senderPermission} access; leaving issue open`);
+ return;
+ }
+
+ const body = issue.body || '';
+ const hasBugConfirmation = bugConfirmationPattern.test(body);
+ const hasBugTemplate = requiredSections.every((section) => body.includes(section));
+ const currentBehavior = extractSection(body, '### Current behavior');
+ const expectedBehavior = extractSection(body, '### Expected behavior');
+ const reproduction = extractSection(body, '### Reproduction');
+ const impact = extractSection(body, '### Impact');
+ const environment = extractSection(body, '### Environment');
+ const hasRequiredContent = [
+ currentBehavior,
+ expectedBehavior,
+ reproduction,
+ impact,
+ ].every(hasContent);
+ const hasEnvironmentFields = hasContent(environment) && requiredEnvironmentFields.every((pattern) => pattern.test(environment));
+ if (hasBugConfirmation && hasBugTemplate && hasRequiredContent && hasEnvironmentFields) {
+ console.log(`#${issue.number} matches the bug report template`);
+ return;
+ }
+
+ const message = [
+ `Hi @${author}, thanks for opening this.`,
+ '',
+ 'This issue does not appear to use the full bug report template. Herdr issues are only for reproducible bugs and maintainer-created or maintainer-converted work items.',
+ '',
+ 'Bug reports must include the reproducible-bug confirmation, current behavior, expected behavior, reproduction, impact, and environment fields with Herdr version, operating system, and terminal.',
+ '',
+ 'Feature requests, ideas, questions, contribution proposals, and direction checks belong in Discussions:',
+ `https://github.com/${context.repo.owner}/${context.repo.repo}/discussions`,
+ '',
+ 'Please read the contributing guidelines before opening issues or PRs:',
+ `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`,
+ '',
+ 'Closing this so the issue tracker stays limited to actionable bug reports.',
+ ].join('\n');
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: message,
+ });
+
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ state: 'closed',
+ state_reason: 'not_planned',
+ });
diff --git a/.github/workflows/label-intends-to-pr.yml b/.github/workflows/label-intends-to-pr.yml
deleted file mode 100644
index d25985b7..00000000
--- a/.github/workflows/label-intends-to-pr.yml
+++ /dev/null
@@ -1,92 +0,0 @@
-name: Label contributor intent
-
-on:
- issues:
- types: [opened, edited]
-
-permissions:
- issues: write
-
-env:
- INTENDS_TO_PR_LABEL: intends-to-pr
-
-jobs:
- label:
- if: ${{ !github.event.issue.pull_request }}
- runs-on: ubuntu-latest
-
- steps:
- - name: Sync intends-to-pr label
- uses: actions/github-script@v9
- with:
- script: |
- const label = process.env.INTENDS_TO_PR_LABEL;
- const body = context.payload.issue.body || '';
- const issueNumber = context.payload.issue.number;
- const { owner, repo } = context.repo;
- const checkedIntentPattern = /^\s*-\s*\[[xX]\]\s*I intend to open a PR for this (?:change|issue)\.\s*$/m;
- const uncheckedIntentPattern = /^\s*-\s*\[ \]\s*I intend to open a PR for this (?:change|issue)\.\s*$/m;
- const cliIntentPattern = /^\s*\/i-intend-to-pr\s*$/m;
- const shouldAddLabel = checkedIntentPattern.test(body) || cliIntentPattern.test(body);
- const shouldRemoveLabel = uncheckedIntentPattern.test(body);
- const labels = context.payload.issue.labels.map((item) =>
- typeof item === 'string' ? item : item.name,
- );
- const hasLabel = labels.includes(label);
-
- if (shouldAddLabel && hasLabel) {
- console.log(`${label} is already present on #${issueNumber}.`);
- return;
- }
-
- if (shouldRemoveLabel && !hasLabel) {
- console.log(`${label} is already absent from #${issueNumber}.`);
- return;
- }
-
- if (!shouldAddLabel && !shouldRemoveLabel) {
- console.log(`No contribution intent signal found for #${issueNumber}.`);
- return;
- }
-
- if (shouldAddLabel) {
- try {
- await github.rest.issues.getLabel({ owner, repo, name: label });
- } catch (error) {
- if (error.status !== 404) {
- throw error;
- }
-
- await github.rest.issues.createLabel({
- owner,
- repo,
- name: label,
- color: '0E8A16',
- description: 'Contributor intends to open a PR for this issue.',
- });
- }
-
- await github.rest.issues.addLabels({
- owner,
- repo,
- issue_number: issueNumber,
- labels: [label],
- });
- console.log(`Added ${label} to #${issueNumber}.`);
- return;
- }
-
- try {
- await github.rest.issues.removeLabel({
- owner,
- repo,
- issue_number: issueNumber,
- name: label,
- });
- console.log(`Removed ${label} from #${issueNumber}.`);
- } catch (error) {
- if (error.status !== 404) {
- throw error;
- }
- console.log(`${label} was already absent from #${issueNumber}.`);
- }
diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml
index f59f49ad..fadc4d28 100644
--- a/.github/workflows/pr-gate.yml
+++ b/.github/workflows/pr-gate.yml
@@ -15,6 +15,7 @@ jobs:
- name: Check if contributor is approved
uses: actions/github-script@v9
with:
+ github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
const prAuthor = context.payload.pull_request.user.login;
const reopener = context.payload.sender?.login ?? null;
@@ -102,15 +103,19 @@ jobs:
const message = [
`Hi @${prAuthor}, thanks for your interest in contributing!`,
'',
- 'We ask new contributors to open an issue first before submitting a PR. This helps us discuss the approach and avoid wasted effort.',
+ 'New contributors need maintainer approval before opening PRs. This keeps review time focused on accepted work and avoids wasted effort.',
'',
- 'Herdr is opinionated about how it should look, feel, and work. If your change affects UI, behavior, interaction patterns, or product direction, discuss it in an issue first.',
+ 'Herdr is opinionated about how it should look, feel, and work. Feature requests, ideas, questions, contribution proposals, and product-direction checks belong in Discussions, not issues.',
'',
'**Next steps:**',
- '1. Open an issue describing what you want to change and why',
- '2. Keep it concise and write in your own voice',
- '3. If a maintainer approves with `/approve`, you will be added to the approved contributors list',
- '4. Then you can submit your PR',
+ '1. If this is a feature or behavior change, open a Discussion describing what you want to change and why',
+ '2. If the work is accepted, a maintainer may convert the discussion into an issue or create a new issue for it',
+ '3. If this is a reproducible bug, open a bug report using the issue template',
+ '4. Wait for maintainer approval on an accepted issue before opening a PR',
+ ' Maintainers approve first-time PR paths with `/approve @your-github-username` on the accepted issue.',
+ '5. Keep it concise and write in your own voice',
+ '',
+ 'A discussion, issue, branch, or proposed implementation does not reserve the work and does not mean the PR path is approved.',
'',
`This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`,
].join('\n');