Skip to content

GitHub issues as work ​

Until v0.14.0 the only thing Hephaisto could be handed was an alert. With github.enabled, a GitHub issue assigned to Hephaisto's account is work too: the Job that plans a code fix for an escalated incident plans one for the issue, the plan is posted on the issue, an approver answers it there, and the pull request that follows closes the issue.

It ships off. Without github.enabled the agent holds no GitHub credential and asks GitHub nothing.

What it does, and what it does not ​

It doesIt does not
Ask GitHub, every github.pollInterval, which open issues are assigned to its account in the repositories you listedTake an issue from a repository that is not listed, however it is assigned
Plan a taken issue with a read-only coder Job, under codeFix.mode and every codeFix capInvestigate anything: there is no incident, no alert and no running image behind an issue
Post the plan on the issue, and say there how to answer itAct on a comment's text. What is approved is the plan as Hephaisto stored it
After an approval, push one branch and open a draft pull request whose description says Closes owner/repo#nMerge, review, deploy, or open anything but a draft
Follow that pull request until it is merged (the work item is done) or closedPlan the same issue twice. To have it planned again, hand it over again
Stop — and delete a running Job — when the account is unassigned or the issue is closedRead an edit of the issue made after it was taken: the text travels as it was then

Hephaisto only asks. Nothing has to reach in, and there is no webhook to expose.

The bot account ​

An ordinary GitHub account — a machine user — and not a GitHub App: an App cannot be an assignee. Give it its own e-mail address and two-factor authentication.

  • Make it a member of the organisation that owns the repositories.
  • Give it write access to each repository it may work in. That is what makes it assignable, and what lets its token push a branch. GitHub answers an assignment it ignores with success, so an account that is not assignable looks exactly like one nobody assigned.

Pull requests are then opened by the bot, so a person can review them as themselves.

Two tokens, and they must stay two ​

Signed in as the bot, create two fine-grained personal access tokens. For both: resource owner = the organisation, and repository access limited to the repositories you list — not "all repositories".

TokenRepository permissionsWho holds it
The agent'sIssues: read and write · Pull requests: readthe agent's pod
The coder'sContents: read and write · Pull requests: read and writethe prepare and publish containers of a coder Job — never the container the model runs in

Metadata: read is added by GitHub to both.

The agent's token must not be able to push. The agent reads every issue anybody can open; the process that does that should hold nothing that writes to a branch. The chart refuses one Secret name for both tokens, for that reason.

These permissions were measured against github.com, not read from its documentation: the agent's token is also enough to ask whose token it is and what a repository's default branch is, and to set and list the reactions on a plan comment; the coder's clones, pushes the one branch, opens the pull request and assigns it.

A repository a user owns

A fine-grained token only reaches repositories owned by its own account or by an organisation it belongs to. For a repository owned by a user the bot is a collaborator, and that needs a classic token (repo) — which reaches every repository the bot can see. Prefer an organisation.

The two Secrets ​

sh
# The agent's, in the release namespace.
kubectl -n hephaisto create secret generic hephaisto-github \
  --from-literal=GITHUB_TOKEN=github_pat_...

# The coder's, in the coder namespace: the Secret code fixes already use. The agent cannot read it.
kubectl -n hephaisto-coder create secret generic hephaisto-codefix \
  --from-literal=GITHUB_TOKEN=github_pat_... \
  --from-literal=CLAUDE_CODE_OAUTH_TOKEN=...

Chart values ​

yaml
github:
  enabled: true
  issues:
    # THE AUTHORIZATION LIST. An issue anywhere else is never asked about.
    repositories:
      - example/shop
  # Who may answer a plan ON THE ISSUE, by account NUMBER.
  approvers:
    - 1234567
  # pollInterval: "00:01:00"   # GitHub's own advice for a poller
  # botLogin: ""               # empty: whoever the token belongs to
  # apiBaseUrl: https://api.github.com
  # useEgressProxy: true       # through codeFix.egressProxy, when that is rendered

secrets:
  github: hephaisto-github     # key GITHUB_TOKEN; never the same name as secrets.codeFix

codeFix:
  enabled: true                # an issue is planned by a coder Job
  mode: "plan"                 # "off" | "plan" | "pr" - see below. Quote it: YAML reads a bare off as false

Every key is in the Helm values reference with the reason for it.

Approvers are numbers ​

github.approvers holds account ids, not logins: a login can be given up and registered by somebody else, a number cannot. A login in that list is refused at render and at start.

sh
gh api users/<login> --jq .id

Whoever is listed can make Hephaisto push a branch to every listed repository. List maintainers, not everybody who may comment.

With the list empty, no comment is read at all; the plan says that it is answered in the console.

Answering a plan ​

With a click. Below its plan comment Hephaisto sets two reactions itself, 🚀 and 👎, so that GitHub shows them there. An approver who clicks 🚀 approves that plan; one who clicks 👎 rejects it. A reaction counts on the plan comment only, so it answers that plan and no other - a 🚀 on an earlier plan's comment approves nothing once a new plan was written. No other reaction is an answer, the thumbs-up least of all: it is what people set on a comment they have merely read. Hephaisto's own two are never an answer. A reaction carries no text, so a rejection by 👎 is recorded without a reason, and there is no reaction for /replan.

A reaction is read once. One that was refused - the mode is plan, say - does not approve by itself when the cause is gone: take it off and set it again, or write the command.

Or with a command. On the issue, an approver writes a comment whose first line is exactly

text
/approve

or

text
/reject the null is the caller's to handle

or, to have the issue planned again with what was written on it since,

text
/replan

Nothing else is a command — not /approve please, not LGTM /approve, not a quotation or a code block of either, not /Approve, not /replan with the second option. Strict on purpose: a comment wrongly read as an approval pushes a branch. The first such comment after the plan decides, once; a comment that was read is never read again, whatever it is edited into.

GitHub's comment editor suggests none of the three - its / menu is GitHub's own - and a mistyped one is passed over in silence. So the plan comment shows each in a block of its own, which GitHub gives a copy button. A saved reply per command, set up once by each approver, puts them into that menu after all.

One click starts a Job that pushes a branch. Unassigning Hephaisto stops it.

The same plan can be answered in the console: work items lists what was taken, and each attempt has a page — /codefixes/<attempt id> — with the plan in full, its history, and approve and deny for whoever holds the approver role. Or through the API, POST /api/workitems/{id}/codefix/{attemptId}/approve and .../deny.

Questions, answers and a new plan ​

A planner cannot ask while it runs, so it asks in its result. A plan comment has a section Questions — a numbered list of what only a person can decide, each with what the plan assumed in the meantime — and, folded below it, the planner's notes: what it left out and why. "Should 'Legacy Queue' move too? The plan leaves it where it is" is a question; before v0.14.0 it was a note nobody was shown.

Three things can then be said:

  • /approve takes the plan as it is, with its assumptions.
  • An answer, then /replan. Answer in a comment like any other — by number, in a sentence — and reply /replan, in the same comment after that first line or in a later one. The waiting plan ends (it is recorded as rejected, with the reason replanned by github:<login>), a new attempt plans the same issue, and a second plan comment follows that says it replaces the first.
  • /reject <reason>.

What a replanning Job is given. The earlier plan — its summary, its questions and its steps — and the comments written on the issue since it was handed over by the issue's author and by the approvers, oldest first. Nobody else's comment reaches a Job, and none of Hephaisto's own. The newest fifty are passed on, each cut at 16,000 characters, and credential shapes are scrubbed as they are from the issue's text. The issue's title and text are read again at /replan — it is an explicit act by an approver. A silent edit of the issue is still not picked up.

When /replan is taken, and when not.

The newest attempt/replan by an approver
its plan is waitingtaken: that plan ends, a new one is made
it did not work, was rejected, expired or was stoppedtaken: a new attempt for the same issue
a Job is running for it (planning, implementing)refused, in one sentence, once
it opened a pull requestrefused, in one sentence, once: the work goes on in the review

A /replan that is taken and cannot start yet — the daily cap, the one Job slot, the mode off — waits like a first plan does: the status comment says what it waits for, and Hephaisto asks again by itself.

An attempt that did not work — insufficient_context, not_a_code_problem, a failure — has no plan comment, so the status comment shows what its planner found, asked and noted, and says how to go on: an approver answers and replies /replan.

At most five attempts for one hand-over. After the fifth, /replan is refused and the status comment says so. Unassign the account, wait until the status comment says Hephaisto has let go, and assign it again: that is a new work item.

Assigning again ​

For an issue whose newest attempt has ended without a pull request, unassigning the account and assigning it again is the same as /replan without an answer — however quickly it is done. Hephaisto reads the issue's timeline for it (GET /repos/{owner}/{repo}/issues/{n}/timeline, conditionally, so an unchanged timeline costs nothing against the rate limit) and takes an assigned event for its account that is newer than the attempt's end as a new hand-over.

Only for those issues: for an issue whose plan is waiting, or whose Job is running, the timeline is not read at all, and assigning again does not answer a waiting plan — it is left alone.

What Hephaisto writes on an issue ​

  • One status comment, edited in place as the work moves: planning, plan ready, implementing, the pull request, how it ended.
  • One comment with the plan per attempt — summary, what will change, files, steps, verification, the planner's questions, its notes folded — and how to answer it. Never edited, also not when a new plan replaces it.
  • Two reactions on its own plan comment, 🚀 and 👎, for an approver to click - only 👎 on a plan that cannot be approved on the issue, and none on an install that names no approver.
  • At most one answer to people who are not approvers, per plan, however many of them write a command or click a reaction: it names the first in a code span, mentions nobody and does not say who the approvers are.
  • At most one answer per cause when an approver's command or reaction is refused — the mode is plan or off, the emergency stop is engaged, the plan needs a change in a second repository, a Job is running, a pull request is open.

Never more than five comments for one attempt — its plan and four answers — whatever anybody writes there, and at most five attempts for one hand-over: with the status comment, never more than 26 comments for one issue it was handed. Beyond a ceiling it only edits its status comment.

Nothing of the issue's own text is repeated in a comment, and what a model wrote is posted with a zero-width space wherever GitHub would act on it — after @, # and GH-, inside ://, after every / before a digit — so that it mentions nobody and closes nothing. A path or an address copied out of a plan comment or a pull request's description carries that character. A code span the model wrote is posted as it was written: GitHub acts on nothing inside one.

One kind of note is counted and not quoted: a note that speaks of injected text. That is where a planner quotes what somebody planted in the issue, and a comment is written under Hephaisto's name. An operator reads it on the attempt's page in the console.

The mode decides what an issue gets ​

codeFix.mode is the same switch incidents' code fixes obey, and the agent's kill switch overrides it.

codeFix.modeA taken issue/approve
offNo Job starts. The status comment says so, and the issue is asked about again by itself once the mode changesrefused, in one sentence
planA read-only Job writes a plan; it is posted on the issuerefused, in one sentence. The plan is not used up: after the mode is pr, a new /approve is taken
prAs planstarts the implementing Job: one branch, one pull request

Setting the mode to off cancels every open attempt, as it does for incidents.

Three things the context repository has to say ​

Code fixes read a context repository (codeFix.contextRepository, the one with repos.yaml). For an issue's repository:

  • repos.yaml must enable it — its entry with coderEnabled: true, and its build and test commands. The chart's list is the operator's authorization and this is the runner's own opt-in; a repository needs both. For one that is listed only in the chart the Job refuses before any model runs, and the attempt fails with repository not enabled in dev-context repos.yaml.
  • The label named in defaults.pr.labels must exist in the repository. gh refuses a label that does not; the pull request is then opened without it and the attempt's deviations say so.
  • defaults.pr.draft says when a pull request is a draft. true: always. unless-ready: only when the runner had no check to run for the repository, or when a deviation from the approved plan was reported - by the runner or by the model. Otherwise it is opened ready for review. The last lines of its description say which, and why. Either way only a person merges, and a change whose build or tests fail is never pushed.

Who else is told ​

A work item's code fix announces the three moments an incident's does — CodeFixPlanReady, CodeFixPrOpened, CodeFixFailed — through the notification outbox. The message names the issue by its reference and links the attempt's page.

There is no incident behind it, so there is no severity, namespace, cluster, kind or label to route by. Give work items a route of their own: one without namespaces, clusters, kinds or matchers, and without a minSeverity above Info.

yaml
notifications:
  routes:
    - channel: teamsBot
      events: [CodeFixPlanReady, CodeFixPrOpened, CodeFixFailed]

A route scoped to a namespace or a label does not match a work item, and neither does a fallback route — that one is for incidents nobody else owns. The Teams board stays a board of incidents.

Seeing it work ​

sh
curl -s https://hephaisto.example.com/api/status | jq '.connections[] | select(.name == "github")'
curl -s 'https://hephaisto.example.com/api/workitems?state=any' | jq '.[] | {repository, number, state, stateReason}'

github is Healthy while every listed repository answers, and Degraded with one line of why — a refused token, a rate limit and until when, a 5xx. The row follows the poller by up to a minute. An MCP client asks the same with list_work_items and get_work_item.

Four counters: hephaisto.github.polls by outcome, hephaisto.workitems.taken, hephaisto.workitems.closed by state and reason, and hephaisto.workitems.commands by verb (approve, reject, replan) and by what was done with the command (accepted, not_approver, refused:<cause>).

Handing an issue back ​

Unassign the account, or close the issue: the work item is cancelled, a running Job is deleted, and nothing is pushed afterwards.

An issue whose pull request was merged or closed is not started over while it simply stays assigned. Unassign the account and assign it again a minute or two later — Hephaisto has to have seen the issue without itself on it, which takes one poll — or close and reopen the issue, to hand it over once more; that is a new work item, with the issue's text as it is then.

One install per repository ​

The token is an account's, not an install's. Two installs that hold the same account's token and list the same repository both take an issue assigned to that account: each writes its own status comment, and each plans it with its own coder and its own budget. Nothing tells them about each other. List a repository on one install - and give a test install an account of its own, or a repository no other install lists.

Limits, and what is not built ​

  • Polling only. A new assignment is seen within github.pollInterval. A webhook that tells the poller to look now is not built.
  • A fresh assignment is only known by its time for an attempt that ended. For an issue whose pull request was merged or closed and that stayed assigned, off and on again within one poll interval is still not noticed.
  • A long conversation is read one page at a time. GitHub lists an issue's comments oldest first, a hundred to a page; a replanning Job is given the newest fifty of the first hundred written since the issue was handed over.
  • "Merged is done" was never run against github.com. The test that runs against GitHub itself never merges, so that a merge ends the work item is held against a stand-in only.
  • No real model has planned an issue in a test. Every automated run used a scripted model; what a model makes of an issue's text is yours to watch on the first few.
  • One open attempt per work item, and at most five in a row. The next one is never started by itself: an approver asks for it, or the issue is assigned again.
  • No real model has been asked a question's answer. Whether a planner asks well, and what it makes of an answer, was run with a scripted model only.
  • A commit message is not made inert. The implementing prompt forbids closing keywords there; nothing checks it.

AGPL-3.0-only · Source