-
Notifications
You must be signed in to change notification settings - Fork 43
feat(infra): бот за заявяване на Issue-та (/assign) срещу дублирана работа (#230) #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cefothe
wants to merge
7
commits into
midt-bg:main
Choose a base branch
from
cefothe:feat/issue-claim-upstream
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6e1812d
feat(infra): add issue-claim decision module with node:test coverage
cefothe 593f20a
ci(infra): add /assign issue-claiming and stale-claim sweep workflows
cefothe cf66b13
docs(contributing): document /assign issue-claiming convention
cefothe eb868bd
docs(infra): add implementation plan for the issue-claiming bot (#230)
cefothe 835375c
docs: index the issue-claim-bot implementation plan
cefothe 0413947
fix(infra): parse /assign and /unassign case-insensitively
cefothe ff51dff
fix(infra): scope idle/nudge/linked-PR to the claimer (PR #240 review)
cefothe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| name: assign-issue | ||
|
|
||
| # Implements contributor issue-claiming via /assign and /unassign comments. | ||
| # | ||
| # WHY body-marker: GitHub's Assignees API rejects non-collaborators, so the bot | ||
| # edits the issue body to embed an invisible <!-- sigma-claim: {"user":"..."} --> | ||
| # marker as the claim of record. This mirrors the Rust @rustbot claim precedent | ||
| # (which hit the same API restriction) and matches the <!-- sigma-preview --> marker | ||
| # convention already in use in this repo (preview-reap.yml). | ||
| # | ||
| # ALL decision logic lives in scripts/issue-claim.mjs (pure functions, unit-tested). | ||
| # This workflow is thin I/O glue: checkout → parse → fetch → write. | ||
| # | ||
| # Security: untrusted comment body is passed via env var and read via process.env | ||
| # inside the github-script — never interpolated into the JS string (S1, injection guard). | ||
|
|
||
| on: | ||
| issue_comment: | ||
| types: [created] | ||
|
|
||
| concurrency: | ||
| group: assign-issue-${{ github.event.issue.number }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| claim: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
|
|
||
| # Cheap pre-filter before any API calls: | ||
| # - only issue comments (not PR review comments) — github.event.issue.pull_request is | ||
| # absent on real issues and present on PR threads | ||
| # - skip bot authors to prevent feedback loops (O5) | ||
| # - only /assign or /unassign comment starters | ||
| if: >- | ||
| github.event.issue.pull_request == null && | ||
| github.event.comment.user.type != 'Bot' && | ||
| (startsWith(github.event.comment.body, '/assign') || | ||
| startsWith(github.event.comment.body, '/unassign')) | ||
|
|
||
| permissions: | ||
| issues: write | ||
| contents: read | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
|
|
||
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version: 22 | ||
|
|
||
| - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| env: | ||
| # Pass untrusted comment body via env — never interpolate into the JS string (S1). | ||
| COMMENT_BODY: ${{ github.event.comment.body }} | ||
| with: | ||
| script: | | ||
| const { parseCommand, parseClaimMarker, writeClaim, stripClaim, canUnassign, IN_PROGRESS } | ||
| = await import(`${process.env.GITHUB_WORKSPACE}/scripts/issue-claim.mjs`); | ||
|
|
||
| const actor = context.payload.comment.user.login; | ||
| const { owner, repo } = context.repo; | ||
| const issue_number = context.payload.issue.number; | ||
|
|
||
| // Authoritative command parse from the env-isolated body (S1). | ||
| const command = parseCommand(process.env.COMMENT_BODY); | ||
| if (!command) return; | ||
|
|
||
| // Fetch the current issue body (source of truth for the claim marker). | ||
| const { data: issueData } = await github.rest.issues.get({ owner, repo, issue_number }); | ||
| let body = issueData.body || ''; | ||
| let claim = parseClaimMarker(body); | ||
|
|
||
| // ── /assign ────────────────────────────────────────────────────────── | ||
| if (command === '/assign') { | ||
| if (claim && claim.user !== actor) { | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `Задачата вече е заета от @${claim.user}. ` + | ||
| `Заетостта се освобождава автоматично след 21 дни неактивност. ` + | ||
| `Ако искаш да работиш по нея, изчакай или се свържи с @${claim.user}.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (claim && claim.user === actor) { | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `Задачата вече е заета от теб (@${actor}). ` + | ||
| `Напиши \`/unassign\` ако се откажеш.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Write the claim with a 3-attempt retry on concurrent body edits (O2). | ||
| let updateError; | ||
| for (let attempt = 1; attempt <= 3; attempt++) { | ||
| try { | ||
| await github.rest.issues.update({ | ||
| owner, repo, issue_number, | ||
| body: writeClaim(body, actor), | ||
| }); | ||
| updateError = null; | ||
| break; | ||
| } catch (error) { | ||
| if (error.status === 409 || error.status === 422) { | ||
| updateError = error; | ||
| // Re-read the body before retrying so we apply onto the latest version. | ||
| const { data: fresh } = await github.rest.issues.get({ owner, repo, issue_number }); | ||
| body = fresh.body || ''; | ||
| claim = parseClaimMarker(body); | ||
| // If someone else claimed while we were retrying, bail out gracefully. | ||
| if (claim && claim.user !== actor) { | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `Задачата беше заета от @${claim.user} точно преди теб. ` + | ||
| `Свържи се с тях ако смяташ да работиш по нея.`, | ||
| }); | ||
| return; | ||
| } | ||
| await new Promise((r) => setTimeout(r, attempt * 500)); | ||
| } else { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| if (updateError) throw updateError; | ||
|
|
||
| // Add the in-progress label — best-effort; 404/422 are benign (label may not exist yet). | ||
| try { | ||
| await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [IN_PROGRESS] }); | ||
| } catch (error) { | ||
| if (error.status !== 404 && error.status !== 422) { | ||
| core.warning(`addLabels(${IN_PROGRESS}): ${error.status} ${error.message}`); | ||
| } | ||
| } | ||
|
|
||
| // Assign the actor — best-effort; non-collaborators get a 422 and that is expected. | ||
| try { | ||
| await github.rest.issues.addAssignees({ owner, repo, issue_number, assignees: [actor] }); | ||
| } catch (error) { | ||
| if (error.status !== 404 && error.status !== 422) { | ||
| core.warning(`addAssignees(${actor}): ${error.status} ${error.message}`); | ||
| } | ||
| } | ||
|
|
||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `Задачата е заета от @${actor}! ` + | ||
| `Когато отвориш PR, сложи \`Closes #${issue_number}\` в описанието. ` + | ||
| `Ако се откажеш, напиши \`/unassign\` за да освободиш задачата.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // ── /unassign ──────────────────────────────────────────────────────── | ||
| if (command === '/unassign') { | ||
| // Resolve privilege via collaborator permission level (O6). | ||
| let privileged = false; | ||
| try { | ||
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner, repo, username: actor, | ||
| }); | ||
| privileged = ['admin', 'write'].includes(data.permission); | ||
| } catch { | ||
| privileged = false; | ||
| } | ||
|
|
||
| if (!claim) { | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `Няма активно заемане на тази задача.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (!canUnassign({ actor, claimedUser: claim.user, privileged })) { | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `Само @${claim.user} или maintainer може да освободи тази задача.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| await github.rest.issues.update({ | ||
| owner, repo, issue_number, | ||
| body: stripClaim(body), | ||
| }); | ||
|
|
||
| // Remove label — 404 means label was never added or already removed (benign). | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number, name: IN_PROGRESS, | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404 && error.status !== 422) { | ||
| core.warning(`removeLabel(${IN_PROGRESS}): ${error.status} ${error.message}`); | ||
| } | ||
| } | ||
|
|
||
| // Remove assignee — 404/422 benign (was never formally assigned). | ||
| try { | ||
| await github.rest.issues.removeAssignees({ | ||
| owner, repo, issue_number, assignees: [claim.user], | ||
| }); | ||
| } catch (error) { | ||
| if (error.status !== 404 && error.status !== 422) { | ||
| core.warning(`removeAssignees(${claim.user}): ${error.status} ${error.message}`); | ||
| } | ||
| } | ||
|
|
||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `Задачата е освободена от @${claim.user}. ` + | ||
| `Вече е свободна — коментирай \`/assign\` ако искаш да я вземеш.`, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| name: stale-assignment-check | ||
|
|
||
| # Daily sweep of open issues labelled 'status: in-progress'. | ||
| # | ||
| # Purpose: auto-release zombie claims so contributors who picked up an issue and | ||
| # went quiet don't block others indefinitely. | ||
| # | ||
| # Windows: | ||
| # 14 days of inactivity → nudge comment (Bulgarian) with <!-- sigma-nudge -->. | ||
| # 21 days of inactivity → release: strip claim marker, remove label + assignee, | ||
| # post release comment (Bulgarian) with <!-- sigma-release -->. | ||
| # | ||
| # The invisible markers (<!-- sigma-nudge --> / <!-- sigma-release -->) make re-runs | ||
| # idempotent: shouldNudge() detects a prior nudge by marker only, not prose. | ||
| # | ||
| # Linked-PR awareness: if a cross-referenced PR is still open the issue is skipped | ||
| # regardless of idle time (fork-source PRs included — hasOpenLinkedPr reads state | ||
| # off the timeline event, no second API call required). | ||
| # | ||
| # All decision logic lives in scripts/issue-claim.mjs (pure functions, unit-tested). | ||
| # This workflow is thin I/O glue: paginate → call pure fn → write result. | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: '17 6 * * *' # daily at 06:17 UTC | ||
| workflow_dispatch: {} | ||
|
|
||
| concurrency: | ||
| group: stale-assignment-check | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| sweep: | ||
| runs-on: ubuntu-latest | ||
| # Paging through potentially many issues + fetching timelines; cap well under | ||
| # the 360-min default so a runaway paginate can't pin a runner all day. | ||
| timeout-minutes: 10 | ||
| permissions: | ||
| issues: write | ||
| pull-requests: read | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
|
|
||
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version: 22 | ||
|
|
||
| - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const { parseClaimMarker, stripClaim, computeIdleDays, hasOpenLinkedPr, shouldNudge, | ||
| IN_PROGRESS, NUDGE_DAYS, RELEASE_DAYS, NUDGE_MARKER, RELEASE_MARKER } | ||
| = await import(`${process.env.GITHUB_WORKSPACE}/scripts/issue-claim.mjs`); | ||
|
|
||
| const { owner, repo } = context.repo; | ||
| const nowMs = Date.now(); | ||
|
|
||
| // Paginate all open issues labelled 'status: in-progress'. | ||
| const issues = await github.paginate( | ||
| github.rest.issues.listForRepo, | ||
| { owner, repo, state: 'open', labels: IN_PROGRESS, per_page: 100 }, | ||
| ); | ||
|
|
||
| for (const issue of issues) { | ||
| // Isolate each issue so one poisoned entry cannot abort the whole sweep (S2). | ||
| try { | ||
| // PRs are returned by listForRepo; skip them. | ||
| if (issue.pull_request) continue; | ||
|
|
||
| const issue_number = issue.number; | ||
|
|
||
| // Skip issues that carry the label but no valid claim record — | ||
| // leave those for humans to triage. | ||
| const claim = parseClaimMarker(issue.body || ''); | ||
| if (!claim) continue; | ||
|
|
||
| // Fetch the full timeline for idle-time + linked-PR calculations. | ||
| const timeline = await github.paginate( | ||
| github.rest.issues.listEventsForTimeline, | ||
| { owner, repo, issue_number, per_page: 100 }, | ||
| ); | ||
|
|
||
| // If an open PR is already linked, work is in flight — skip (O3). | ||
| if (hasOpenLinkedPr(timeline)) continue; | ||
|
|
||
| const idle = computeIdleDays({ timeline, createdAt: issue.created_at, nowMs }); | ||
|
|
||
| if (idle >= RELEASE_DAYS) { | ||
| // ── Release path ───────────────────────────────────────────── | ||
| // Strip the claim from the issue body. | ||
| await github.rest.issues.update({ | ||
| owner, repo, issue_number, | ||
| body: stripClaim(issue.body || ''), | ||
| }); | ||
|
|
||
| // Remove the 'status: in-progress' label; swallow 404/422 only (O4). | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number, name: IN_PROGRESS, | ||
| }); | ||
| } catch (labelErr) { | ||
| if (labelErr.status !== 404 && labelErr.status !== 422) { | ||
| core.warning(`#${issue_number}: removeLabel failed (${labelErr.status}): ${labelErr.message}`); | ||
| } | ||
| } | ||
|
|
||
| // Remove the assignee; swallow 404/422 only (O4). | ||
| try { | ||
| await github.rest.issues.removeAssignees({ | ||
| owner, repo, issue_number, | ||
| assignees: [claim.user], | ||
| }); | ||
| } catch (assignErr) { | ||
| if (assignErr.status !== 404 && assignErr.status !== 422) { | ||
| core.warning(`#${issue_number}: removeAssignees failed (${assignErr.status}): ${assignErr.message}`); | ||
| } | ||
| } | ||
|
|
||
| // Post a Bulgarian release comment with the invisible marker (O1). | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `${RELEASE_MARKER}\n@${claim.user} Заявката ти за тази задача беше автоматично освободена след 21 дни липса на активност. Ако искаш да продължиш, коментирай \`/assign\` за да я заявиш отново.`, | ||
| }); | ||
|
|
||
| } else if (shouldNudge({ idleDays: idle, nudgeDays: NUDGE_DAYS, timeline, nudgeMarker: NUDGE_MARKER })) { | ||
| // ── Nudge path ─────────────────────────────────────────────── | ||
| // Post a Bulgarian nudge comment with the invisible marker (O1). | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number, | ||
| body: `${NUDGE_MARKER}\n@${claim.user} Все още работиш ли по тази задача? Не сме виждали активност от ${Math.floor(idle)} дни. Ако се окажеш в затруднение, питай в коментар. При липса на активност задачата ще бъде освободена автоматично след 21 дни.`, | ||
| }); | ||
| } | ||
|
|
||
| } catch (e) { | ||
| core.warning(`stale-assignment-check: skipping issue #${issue.number}: ${e.message}`); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Незначително. Гейтът
startsWith(github.event.comment.body, '/assign')работи върху суровото тяло, докатоparseCommandправиbody.trim(). Коментар с водещи интервали (напр./assign) няма да задейства job-а изобщо, въпреки чеparseCommandби го приел — лек UX разнобой. Освен товаstartsWithпуска job и за/assignee,/assign-me(после безобиден no-op през exact-word guard-а), т.е. се хаби runner. Не е блокиращо; помисли за подравняване на условието (trim/regex) сparseCommand.