Skip to content
217 changes: 217 additions & 0 deletions .github/workflows/assign-issue.yml
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') ||

Copy link
Copy Markdown
Contributor

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.

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\` ако искаш да я вземеш.`,
});
}
138 changes: 138 additions & 0 deletions .github/workflows/stale-assignment-check.yml
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}`);
}
}
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
- За **уязвимост в сигурността** — **не** отваряйте публичен issue. Вижте политиката за
сигурност (частен канал) в раздела **Security** на хранилището.
- За по-голяма промяна е добре първо да я обсъдим в issue, преди да отделяте време за код.
- Преди да започнете работа по съществуващ issue, коментирайте `/assign`, за да го заявите
(и `/unassign`, ако се откажете) — така избягваме двама души да работят по едно и също. Сложете
`Closes #NN` в PR-а, за да се свърже с issue-то.

## Среда за разработка

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [`etl-pipeline-state.md`](etl-pipeline-state.md) — анализ на текущото състояние на ETL pipeline-а.
- [`etl-architecture.md`](etl-architecture.md) — целевата ETL архитектура (RFC): предложение за състоянието и реда на изпълнение.
- [`v1-implementation-plan.md`](v1-implementation-plan.md) — precompute слоят и пагинацията (защо rollup-и и keyset вместо per-request GROUP BY / OFFSET).
- [`implementation-plans/230-issue-claim-bot.md`](implementation-plans/230-issue-claim-bot.md) — план за внедряване на бота за заявяване на Issue-та (`/assign`) срещу дублирана работа (#230).
- [`integrity-gate.md`](integrity-gate.md) — reconciliation gate-ът: hard asserts върху тоталите при import/CI.
- [`anomaly-report.md`](anomaly-report.md) — cross-row аномалии при опресняване: какво `value_flag` не хваща на ниво отделен договор.
- [`deploy.md`](deploy.md) — деплой към Cloudflare: двата Worker-а (`sigma`, `sigma-etl`) и споделеният D1 per environment.
Expand Down
Loading