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
59 changes: 13 additions & 46 deletions .github/workflows/wave-claim.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Wave Claim
name: Wave Application Guidance

on:
issue_comment:
Expand All @@ -9,13 +9,13 @@ permissions:
issues: write

jobs:
claim:
name: Process contributor claim command
guidance:
name: Direct claim commands to Drips
if: github.event.issue.pull_request == null
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Claim or release Wave issue
- name: Explain the official assignment process
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
Expand All @@ -26,61 +26,28 @@ jobs:
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 { 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;
}
}
if (!labels.has('Stellar Wave')) {
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.',
body: '@' + actor + ', this issue is not currently active in the Drips Stellar Wave program. Please wait for a maintainer to activate the issue before applying.',
});
return;
}

if (!issue.assignees.some((a) => a.login === actor)) {
if (command === '/claim') {
await github.rest.issues.createComment({
owner, repo, issue_number: issueNumber,
body: '@' + actor + ', you can only unclaim an issue currently assigned to you.',
body: '@' + actor + ', Stellar Wave issues are assigned through the Drips application process, not by GitHub self-claim. Please apply for this issue in Drips and wait for maintainer acceptance and GitHub assignment before starting substantial work.',
});
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.',
body: '@' + actor + ', release or withdraw your assignment through Drips and notify the maintainer. This command does not automatically change the official Drips or GitHub assignment.',
});
2 changes: 1 addition & 1 deletion .github/workflows/wave-pr-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
}

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.');
core.setFailed('PR author must be accepted through Drips and assigned to the linked Wave issue before merge.');
return;
}

Expand Down
150 changes: 150 additions & 0 deletions .github/workflows/wave-pr-readiness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: Wave PR Readiness

on:
workflow_run:
workflows:
- CI
- CodeQL
- Dependency Review
- E2E Smoke
- Contributor PR Guard
types: [completed]

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

concurrency:
group: wave-pr-readiness-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true

jobs:
readiness:
name: Mark green Wave PRs ready for review
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reconcile review readiness
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const labelName = 'review: ready';
const requiredWorkflows = new Set([
'CI',
'CodeQL',
'Dependency Review',
'E2E Smoke',
'Contributor PR Guard',
]);

try {
await github.rest.issues.getLabel({ owner, repo, name: labelName });
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner,
repo,
name: labelName,
color: '0e8a16',
description: 'Current PR head passed the required automated Wave checks',
});
}

const run = context.payload.workflow_run;
let pullRefs = run.pull_requests || [];
if (!pullRefs.length) {
const openPulls = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
pullRefs = openPulls.filter((pull) => pull.head.sha === run.head_sha);
}

for (const pullRef of pullRefs) {
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: pullRef.number,
});
const prLabels = new Set(pr.labels.map((label) => label.name));

const removeReadyLabel = async () => {
if (!prLabels.has(labelName)) return;
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: labelName,
});
} catch (error) {
if (error.status !== 404) throw error;
}
};

if (pr.state !== 'open' || pr.draft) {
await removeReadyLabel();
continue;
}

const ref = (pr.body || '').match(
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i,
);
if (!ref) {
await removeReadyLabel();
continue;
}

let issue;
try {
({ data: issue } = await github.rest.issues.get({
owner, repo, issue_number: Number(ref[1]),
}));
} catch {
await removeReadyLabel();
continue;
}
if (!issue.labels.some((label) => label.name === 'Stellar Wave')) {
await removeReadyLabel();
continue;
}
if (!issue.assignees.some((assignee) => assignee.login === pr.user.login)) {
await removeReadyLabel();
continue;
}

const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner,
repo,
head_sha: pr.head.sha,
event: 'pull_request',
per_page: 100,
},
);
const latestByWorkflow = new Map();
for (const run of runs) {
if (!requiredWorkflows.has(run.name)) continue;
const current = latestByWorkflow.get(run.name);
if (!current || new Date(run.created_at) > new Date(current.created_at)) {
latestByWorkflow.set(run.name, run);
}
}

const green = [...requiredWorkflows].every((name) => {
const run = latestByWorkflow.get(name);
return run?.status === 'completed' && run?.conclusion === 'success';
});

if (!green) {
await removeReadyLabel();
continue;
}

if (!prLabels.has(labelName)) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [labelName],
});
}
core.info('Wave PR #' + pr.number + ' is ready for maintainer review.');
}
99 changes: 84 additions & 15 deletions .github/workflows/wave-stale-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,55 +8,124 @@ on:
permissions:
contents: read
issues: write
pull-requests: write
pull-requests: read

concurrency:
group: wave-stale-review
cancel-in-progress: false

jobs:
stale:
name: Review inactive contributor PRs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Warn and close stale Wave PRs
- name: Warn and escalate stale Wave PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const WARN_AFTER = 7 * DAY;
const CLOSE_AFTER = 21 * DAY;
const pulls = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 });
const WARN_AFTER = 2 * DAY;
const ESCALATE_AFTER = 4 * DAY;
const WARNING_MARKER = '<!-- stellarforge-wave-stale-warning -->';
const ESCALATION_MARKER = '<!-- stellarforge-wave-stale-escalation -->';
const pulls = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});

for (const pr of pulls) {
if (pr.draft) continue;
const age = now - new Date(pr.updated_at).getTime();
const labels = new Set(pr.labels.map((label) => label.name));
if (labels.has('do-not-close')) continue;

const ref = (pr.body || '').match(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i);
const ref = (pr.body || '').match(
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i,
);
if (!ref) continue;

let issue;
try {
({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: Number(ref[1]) }));
({ data: issue } = await github.rest.issues.get({
owner, repo, issue_number: Number(ref[1]),
}));
} catch {
continue;
}
if (!issue.labels.some((label) => label.name === 'Stellar Wave')) continue;

if (age >= CLOSE_AFTER) {
const [commits, comments, reviewComments] = await Promise.all([
github.paginate(github.rest.pulls.listCommits, {
owner, repo, pull_number: pr.number, per_page: 100,
}),
github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
github.paginate(github.rest.pulls.listReviewComments, {
owner, repo, pull_number: pr.number, per_page: 100,
}),
]);

const activityDates = [new Date(pr.created_at).getTime()];
for (const commit of commits) {
if (commit.author?.login !== pr.user.login) continue;
const date = commit.commit?.author?.date || commit.commit?.committer?.date;
if (date) activityDates.push(new Date(date).getTime());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const comment of [...comments, ...reviewComments]) {
if (comment.user?.login === pr.user.login) {
activityDates.push(new Date(comment.created_at).getTime());
}
}

const lastContributorActivity = Math.max(...activityDates);
const inactiveFor = now - lastContributorActivity;
const workflowComments = comments.filter((comment) =>
[WARNING_MARKER, ESCALATION_MARKER].some((marker) =>
(comment.body || '').includes(marker),
),
);
const warnedSinceActivity = workflowComments.some((comment) =>
(comment.body || '').includes(WARNING_MARKER) &&
new Date(comment.created_at).getTime() > lastContributorActivity,
);
const escalatedSinceActivity = workflowComments.some((comment) =>
(comment.body || '').includes(ESCALATION_MARKER) &&
new Date(comment.created_at).getTime() > lastContributorActivity,
);

if (inactiveFor < WARN_AFTER) {
if (labels.has('status: stale')) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: 'status: stale',
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
continue;
}

if (!labels.has('status: stale')) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: ['status: stale'],
});
}

if (inactiveFor >= ESCALATE_AFTER && !escalatedSinceActivity) {
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: 'Closing this contributor PR after 21 days without activity. The linked issue can be reclaimed. A maintainer can reopen the PR if work resumes.',
body: ESCALATION_MARKER + '\nThis Stellar Wave PR has no contributor commit or progress comment for 4 days. A maintainer should now review whether to retain or release the assignment. The automation does not close the PR or change the Drips assignment.',
});
await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' });
continue;
}
if (age >= WARN_AFTER && !labels.has('status: stale')) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['status: stale'] });

if (!warnedSinceActivity) {
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: 'This PR has had no activity for 7 days. Please post an update or push progress. It may be closed after 21 days of inactivity.',
body: WARNING_MARKER + '\nThis Stellar Wave PR has no contributor commit or progress comment for 48 hours. Please post a concise update, push current progress, or tell the maintainer if the issue should be released.',
});
}
}
Loading
Loading