Rules cookbook
Patterns that work, and the reasoning behind each threshold. Copy them, then tune the numbers against your own dispatch eval output rather than trusting mine.
Ask for a reproduction
yaml
- id: needs-repro
when: [issue.opened, issue.edited]
if: "answers.type == 'bug' && answers.has_repro < 0.3"
then:
- { op: label, add: [needs-repro] }
- { op: comment, template: needs-repro.md, key: needs-repro }< 0.3 rather than < 0.5: a false positive here tells someone who did include a reproduction that they did not, which is annoying enough to lose a contributor.
Area labels from your taxonomy
yaml
labels:
area:
parser: 'Markdown tokenization and AST construction'
renderer: 'PDF drawing, layout, pagination, fonts'
rules:
- id: area-label
when: [issue.opened, pull_request.opened]
if: "answers.label_area != '__none__'"
then:
- { op: label, add: ['area:{{answers.label_area}}'] }Each dimension becomes one Choice question, and your descriptions are the rubric the model reads. Write them for someone who has never seen your repository — that wording does more for quality than any threshold.
Always handle __none__. The model is never forced into a wrong bucket, and a rule that ignores the escape hatch will label everything.
Flag risky changes for careful review
yaml
- id: careful-review
when: [pull_request.opened, pull_request.synchronize]
if: 'answers.risk >= 2.5 || answers.touches_security_surface > 0.7'
gateOn: [risk, touches_security_surface]
then:
- { op: label, add: [needs-careful-review] }
- { op: request_review, codeowners: true }risk is a score, and scores are fractional. >= 2.5 means "at least halfway between 'crosses module boundaries' and 'touches security or release machinery'". Never round it.
gateOn is explicit here because the if uses ||: without it the gate would be the minimum across both questions even when only one of them fired the rule.
Combine signals rather than asking one big question
yaml
- id: needs-tests
when: [pull_request.opened, pull_request.synchronize]
if: 'answers.needs_tests > 0.7 && answers.risk >= 1.5'
then:
- { op: label, add: [needs-tests] }"Untested" alone flags every typo fix. "Untested and non-trivial" is the thing you actually care about. This is the pattern the whole design is built for: decompose the judgement, weigh it in a rule, and tune a number instead of rewriting a prompt.
Close spam — carefully
yaml
allowDestructive: [close]
rules:
- id: spam-gate
when: issue.opened
if: "answers.is_spam > 0.95 && author.association == 'FIRST_TIME_CONTRIBUTOR'"
then:
- { op: label, add: [spam] }
- { op: close, reason: not_planned }
confidence: { auto: 0.97, suggest: 0.9 }Three separate guards, because closing a real report is the worst thing Dispatch can do:
closemust be named inallowDestructive, or it is suppressed no matter what0.95on the answer itselfauto: 0.97on the rule, well above the repository default
The association check matters too: a spam classifier that can close an issue from a long-time contributor is a classifier with too much authority.
Welcome a first contribution
yaml
- id: welcome
when: [pull_request.opened]
if: "author.association == 'FIRST_TIME_CONTRIBUTOR' && answers.low_effort < 0.3"
then:
- { op: comment, template: welcome.md, key: welcome }The low_effort guard stops Dispatch thanking someone warmly for typo-farming.
Escalate stale items
yaml
escalations:
staleNeedsRepro:
afterDays: 14
then:
- { op: comment, template: stale.md, key: stale }
- { op: close, reason: not_planned }Needs the scheduled workflow — "nothing has happened for fourteen days" is not an event GitHub sends.
Things to avoid
Do not gate on a question that is not asked for that trigger. An issue-only question in a pull_request.opened rule means the rule can never fire. dispatch validate warns about exactly this.
Do not set auto below suggest. It makes no sense to apply an action you would not have been willing to propose, and it is a validation error.
Do not reach for close early. Labels are reversible by one click. A closed issue is a contributor deciding not to come back.