Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

Closes #

<!-- Stellar Wave PRs must close exactly one assigned Wave issue. -->

## Scope

<!-- List the work completed. Call out anything intentionally left out. -->
Expand Down Expand Up @@ -46,7 +48,9 @@ Release notes:

## Checklist

- [ ] I read `docs/contributing/wave-architecture.md` for Stellar Wave work
- [ ] I stayed within the agreed issue scope
- [ ] I checked for overlapping open contributor PRs
- [ ] I did not include secrets or sensitive data
- [ ] I reviewed new/updated dependencies if any
- [ ] I considered failure/error paths, not only the happy path
Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/wave-claim.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Wave Claim

on:
issue_comment:
types: [created]

permissions:
contents: read
issues: write

jobs:
claim:
name: Process contributor claim command
if: github.event.issue.pull_request == null
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Claim or release Wave issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issueNumber = context.issue.number;
const actor = context.actor;
const command = (context.payload.comment.body || '').trim().toLowerCase();
if (!['/claim', '/unclaim'].includes(command)) return;

const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: issueNumber });
const labels = new Set(issue.labels.map((label) => label.name));
if (!labels.has('Stellar Wave')) {
core.setFailed('Claim commands are limited to Stellar Wave issues.');
return;
}

if (command === '/claim') {
if (!labels.has('intake: ready') || labels.has('blocked')) {
await github.rest.issues.createComment({
owner, repo, issue_number: issueNumber,
body: 'This issue is not currently open for claims. Please choose an issue labeled intake: ready.',
});
return;
}
if (issue.assignees.length > 0 && !issue.assignees.some((a) => a.login === actor)) {
await github.rest.issues.createComment({
owner, repo, issue_number: issueNumber,
body: 'This issue is already assigned. Please choose another ready issue.',
});
return;
}
await github.rest.issues.addAssignees({ owner, repo, issue_number: issueNumber, assignees: [actor] });
await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: ['status: in progress'] });
for (const name of ['help wanted', 'status: stale']) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name });
} catch (error) {
if (error.status !== 404) throw error;
}
}
await github.rest.issues.createComment({
owner, repo, issue_number: issueNumber,
body: 'Assigned to @' + actor + '. Keep the PR focused on this issue, link it in the PR body, and follow the frozen contributor architecture.',
});
return;
}

if (!issue.assignees.some((a) => a.login === actor)) {
await github.rest.issues.createComment({
owner, repo, issue_number: issueNumber,
body: '@' + actor + ', you can only unclaim an issue currently assigned to you.',
});
return;
}
await github.rest.issues.removeAssignees({ owner, repo, issue_number: issueNumber, assignees: [actor] });
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: 'status: in progress' });
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.issues.addLabels({
owner, repo, issue_number: issueNumber, labels: ['help wanted', 'intake: ready'],
});
await github.rest.issues.createComment({
owner, repo, issue_number: issueNumber,
body: '@' + actor + ' released this issue. It is available for another contributor.',
});
156 changes: 156 additions & 0 deletions .github/workflows/wave-intake.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Wave Intake

on:
push:
branches:
- main
paths:
- '.github/workflows/wave-intake.yml'
- 'docs/contributing/wave-backlog-plan.md'
issues:
types: [closed, reopened, edited]
workflow_dispatch:

permissions:
contents: read
issues: write

concurrency:
group: wave-intake
cancel-in-progress: false

jobs:
sync:
name: Sync Wave dependency and intake state
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Reconcile Wave issues
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const READY_LIMIT = 16;
const FIRST_BATCH = new Set([
'SW-001','SW-002','SW-003','SW-004','SW-005','SW-006',
'SW-007','SW-008','SW-009','SW-010','SW-011','SW-012',
'SW-013','SW-014','SW-019','SW-020'
]);

const managedLabels = [
{ name: 'intake: ready', color: '0e8a16', description: 'Open for contributor claims' },
{ name: 'intake: queued', color: 'c5def5', description: 'Wave backlog not yet open for claims' },
{ name: 'blocked', color: 'b60205', description: 'Blocked by an unresolved dependency' },
{ name: 'status: in progress', color: 'fbca04', description: 'Claimed and actively being worked on' },
{ name: 'status: stale', color: 'd4c5f9', description: 'Needs contributor or maintainer follow-up' },
];

for (const label of managedLabels) {
try {
await github.rest.issues.getLabel({ owner, repo, name: label.name });
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({ owner, repo, ...label });
}
}

const issues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
per_page: 100,
});

const wave = issues
.filter((issue) => !issue.pull_request && /^SW-\d{3}\b/.test(issue.title))
.sort((a, b) => a.title.localeCompare(b.title));

const byId = new Map(
wave.map((issue) => [issue.title.match(/^SW-\d{3}/)[0], issue]),
);

function dependencies(issue) {
const section = (issue.body || '').split(/## Dependencies/i)[1] || '';
const bounded = section.split(/\n---|\n## /)[0];
return [...new Set(bounded.match(/SW-\d{3}/g) || [])];
}

const state = [];
for (const issue of wave) {
const id = issue.title.match(/^SW-\d{3}/)[0];
const deps = dependencies(issue);
const unresolved = deps.filter((dep) => {
const target = byId.get(dep);
return target && target.state === 'open';
});
state.push({ issue, id, unresolved });
}

const seedReady = state.filter(({ issue, id, unresolved }) => {
const labels = new Set(issue.labels.map((label) => label.name));
return FIRST_BATCH.has(id) &&
unresolved.length === 0 &&
!labels.has('status: in progress');
}).length;
const nonSeedReady = state.filter(({ issue, id, unresolved }) => {
const labels = new Set(issue.labels.map((label) => label.name));
return !FIRST_BATCH.has(id) &&
unresolved.length === 0 &&
labels.has('intake: ready') &&
!labels.has('status: in progress');
}).length;

let capacity = Math.max(0, READY_LIMIT - seedReady - nonSeedReady);

for (const entry of state) {
const { issue, id, unresolved } = entry;
const labels = new Set(issue.labels.map((label) => label.name));
const inProgress = labels.has('status: in progress');
const shouldSeed = FIRST_BATCH.has(id);
const mayPromote =
!inProgress &&
unresolved.length === 0 &&
!labels.has('intake: ready') &&
(shouldSeed || capacity > 0);

const add = [];
const remove = [];

if (unresolved.length > 0) {
if (!labels.has('blocked')) add.push('blocked');
if (labels.has('intake: ready')) remove.push('intake: ready');
if (!labels.has('intake: queued')) add.push('intake: queued');
if (labels.has('help wanted')) remove.push('help wanted');
} else {
if (labels.has('blocked')) remove.push('blocked');

if (labels.has('intake: ready') || mayPromote) {
if (!labels.has('intake: ready')) add.push('intake: ready');
if (labels.has('intake: queued')) remove.push('intake: queued');
if (!inProgress && !labels.has('help wanted')) add.push('help wanted');
if (!labels.has('intake: ready') && !shouldSeed) capacity -= 1;
} else {
if (!labels.has('intake: queued')) add.push('intake: queued');
if (labels.has('help wanted')) remove.push('help wanted');
}
}

if (add.length) {
await github.rest.issues.addLabels({
owner, repo, issue_number: issue.number, labels: add,
});
}
for (const name of remove) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: issue.number, name,
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
}

core.info(`Reconciled ${wave.length} Wave issues; ready limit ${READY_LIMIT}.`);
117 changes: 117 additions & 0 deletions .github/workflows/wave-pr-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: Contributor PR Guard

on:
pull_request:
types: [opened, edited, reopened, synchronize]

permissions:
contents: read
issues: read
pull-requests: read

jobs:
guard:
name: Wave PR scope and ownership
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate linked Wave issue and protected scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr = context.payload.pull_request;
const body = pr.body || '';
const refs = [...body.matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi)].map((m) => Number(m[1]));
const uniqueRefs = [...new Set(refs)];
if (uniqueRefs.length !== 1) {
core.setFailed('Wave contributor PRs must use exactly one closing reference such as Closes #123.');
return;
}
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: uniqueRefs[0] });
const labels = new Set(issue.labels.map((label) => label.name));
if (!labels.has('Stellar Wave')) {
core.setFailed('#' + uniqueRefs[0] + ' is not a Stellar Wave issue.');
return;
}
if (issue.state !== 'open') {
core.setFailed('Linked Wave issue is not open.');
return;
}

const dependencySection = (issue.body || '').split(/## Dependencies/i)[1] || '';
const dependencyText = dependencySection.split(/\n---|\n## /)[0];
const dependencyIds = [...new Set(dependencyText.match(/SW-\d{3}/g) || [])];
if (dependencyIds.length) {
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner, repo, state: 'open', per_page: 100,
});
const openWaveIds = new Set(
openIssues
.filter((item) => !item.pull_request && /^SW-\d{3}\b/.test(item.title))
.map((item) => item.title.match(/^SW-\d{3}/)[0]),
);
const unresolved = dependencyIds.filter((id) => openWaveIds.has(id));
if (unresolved.length) {
core.setFailed('Linked issue still has unresolved dependencies: ' + unresolved.join(', '));
return;
}
}

if (!issue.assignees.some((a) => a.login === pr.user.login)) {
core.setFailed('PR author must claim and be assigned to the linked Wave issue before merge.');
return;
}

const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pr.number, per_page: 100,
});
const protectedPatterns = [
/^\.github\/workflows\//, /^\.github\/CODEOWNERS$/, /^\.changeset\//,
/^docs\/adr\//, /^docs\/architecture\//,
/^src\/(?:errors|output|config|process|deployment)\//,
/^src\/generator\/(?:validation|template-generation|template-registry|template-sources)\.ts$/,
/^src\/dev\/(?:orchestrator|environment)\.ts$/,
/^(?:package|package-lock)\.json$/, /^SECURITY\.md$/,
];
const protectedFiles = files.map((f) => f.filename)
.filter((name) => protectedPatterns.some((pattern) => pattern.test(name)));

const currentNames = new Set(files.map((file) => file.filename));
const openPulls = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
const conflicts = [];
for (const other of openPulls) {
if (other.number === pr.number || other.draft) continue;
const otherFiles = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: other.number, per_page: 100,
});
const overlap = otherFiles
.map((file) => file.filename)
.filter((name) => currentNames.has(name))
.filter((name) =>
protectedPatterns.some((pattern) => pattern.test(name)) ||
name.startsWith('src/'),
);
if (overlap.length) conflicts.push('#' + other.number + ': ' + overlap.join(', '));
}
if (conflicts.length) {
core.setFailed('Potential contributor conflict with open PRs: ' + conflicts.join(' | '));
return;
}

if (protectedFiles.length && !labels.has('protected-surface')) {
core.setFailed('PR touches protected surfaces not declared by the linked issue: ' + protectedFiles.join(', '));
return;
}
if (!/##\s+(?:Testing|Tests)/i.test(body)) {
core.setFailed('PR body must include a Testing/Tests section.');
return;
}
if (!/##\s+Security/i.test(body)) {
core.setFailed('PR body must include a Security section.');
return;
}
core.info('Validated Wave issue #' + issue.number + ' for PR #' + pr.number + '.');
Loading
Loading