Update GitHub issues in batches
Update, close and reopen GitHub issues in batches, with the PAT held server-side.
Let your AI groom a GitHub backlog through a pipe whose fine-grained PAT it never touches. Send a list of issues and what should change on each, and every edit applies in one call: retitle, rewrite a body, close, reopen, relabel, reassign. The pipe updates existing issues and never creates one, which keeps every fire repeatable: sending the same batch twice leaves the same result, so a fire whose receipt you could not read is safe to send again. Choose your scope. The github family is a ladder. File only: github-create-issue on its own, which runs on the free Deckhand plan and fits a developer logging bugs without leaving the code. Read only: github-search-issues on its own, which also runs on the free Deckhand plan and needs only a PAT scoped Issues (Read). Read and file: both of those, which needs two pipe slots (Bosun). Full management: add this recipe for three pipe slots (First Mate) and a PAT scoped Issues (Read and write), for a lead or QA grooming the whole backlog. Climbing the ladder stacks pipes rather than swapping them, because each member does something the others cannot, so budget a slot per member you install. This recipe needs github-search-issues in practice, because it addresses issues by node id and search is where node ids come from.
The credential never enters the model context: it lives in the FlurryPORT secret store, deliveries are signed server-side, and every send returns a receipt your agent can quote.
What this is
Your AI grooms a GitHub backlog through a pipe whose fine-grained PAT it never touches. Send a list of issues and what should change on each, and every edit applies in one call.Retitle, rewrite a body, close, reopen, relabel, reassign. Twenty issues in one fire is normal.
Setup in two steps
- GitHub side. Settings, Developer settings, Fine-grained tokens: generate a token for ONLY the repositories this pipe should be able to change, with Issues (Read and write) and Metadata (Read). That token is the entire blast radius of the pipe, so scope it on purpose.
- FlurryPORT side. Your AI requests secret setup and FlurryPORT emails you a secure page. Paste the token there and the pipe goes live.
How to drive it
Issues are addressed by node id, and node ids come from search and read GitHub issues. Search for what you want to change, carry theid values over, and send the edits:items: [
{ "id": "I_kwDOAn8...", "state": "CLOSED" },
{ "id": "I_kwDOAn8...", "body": "Reproduced on 0.4.1." }
]The two pipes are designed to work as a pair: one to see, one to act.
Reading the receipt honestly
This is the part worth knowing before you trust a batch.HTTP 200 does not mean every entry applied. GitHub applies the entries in order and reports each one separately. A bad entry comes back empty with a row in the
errors array naming it, while the others apply normally. A run whose first entry failed still changed everything after it.So check the per-entry results and the
errors array, not the status code.On plans without full response bodies the receipt is capped at 4096 bytes and long batches get cut off. The writes still happened; only your view of them was truncated. Check
ResponseBodyTruncated, and keep batches at twenty or fewer so the receipt stays readable.Why this pipe never creates issues
Updating is repeatable. Send the same batch twice and you get the same end state, which is what makes an unreadable receipt recoverable: just send it again.Creating is not repeatable, and mixing the two would poison that guarantee. Filing lives in create a GitHub issue so the repeatable path and the non-repeatable one stay in different pipes.
Credential custody
The PAT is stored encrypted, added at delivery, and scrubbed from stored responses. Because this pipe can change your backlog, consider turning on endpoint signing so possession of the capture URL is not by itself the authority to edit issues.Install with your agent
npx flurryport mcp
Point your agent at the FlurryPORT MCP server (npx flurryport mcp) and ask it for the flurryport:github-update-issues recipe. Works from AI clients that can run a local process: desktop apps and terminal agents. Web-only chat clients cannot reach a local MCP server; open a desktop client instead.
Tools your agent gains
flry_github_update_issues- Update one or many GitHub issues in a single call. Input: items[], each with id (an issue node id from flry_github_search_issues) plus any of title, body, state (OPEN or CLOSED), labelIds, assigneeIds. Results arrive on the replay execution: read them with get_replay_execution and check BOTH the per-entry results and the errors array, because a partial failure still returns HTTP 200. Updates only, never creates, so a retry cannot duplicate anything.
Setup walkthrough
GITHUB_PAT: GitHub Settings, Developer settings, Fine-grained tokens: generate one for ONLY the repositories this pipe should be able to change, with Issues (Read and write) plus Metadata (Read). This is the whole blast radius of the pipe, so scope it deliberately. Paste it in the FlurryPORT secret page. The same token serves github-search-issues and github-create-issue if you install those too.
Intent schema
{
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"description": "One entry per issue to change. Every entry needs id; everything else is the change you want applied.",
"items": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "The issue node id, as returned in the id field of a github-search-issues result. NOT the issue number people quote."
},
"title": {
"type": "string"
},
"body": {
"type": "string"
},
"state": {
"type": "string",
"description": "OPEN or CLOSED. Closing and reopening are both just a state change."
},
"labelIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Label NODE IDs, not label names, and the list REPLACES the issue's labels rather than adding to them."
},
"assigneeIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "User node ids. Replaces the assignee set."
}
}
}
}
}
}Transformation
{ "body": { "query": "mutation(" & $join([$map($body.items, function($v,$i){ "$in" & $i & ":UpdateIssueInput!" })], ",") & "){" & $join([$map($body.items, function($v,$i){ " m" & $i & ":updateIssue(input:$in" & $i & "){issue{number title state url updatedAt}}" })], "") & " }", "variables": $merge([$map($body.items, function($v,$i){ {("in" & $i): $v} })]) } }Delivery target
POST https://api.github.com/graphql
Placeholders like $secrets.NAME resolve server-side at delivery, never in the agent.
Gotchas
- Bind this pipe's transformation with the predicate $exists($body.items). When two or more github pipes share one capture endpoint, every binding must carry its routing predicate at bind time, and the family's predicates are mutually exclusive by design: github-create-issue routes on $exists($body.title), github-search-issues on $exists($body.q) or $exists($body.ids), github-update-issues on $exists($body.items). An unpredicated binding fires on every intent that reaches the endpoint.
- A partial failure still returns HTTP 200. GitHub applies the entries one after another and reports each under its own key: a failed entry comes back null with a matching row in the errors array naming its path, while every other entry has already been applied. Proven live 2026-08-12: a batch whose FIRST entry carried a bad node id returned 200, m0 null with a NOT_FOUND error, and m1 applied normally. Never read the status code alone. Read the per-entry results and the errors array before reporting success.
- The transformation builds the mutation document from your items, but every value you send rides as a GraphQL variable rather than being pasted into the document text. That is deliberate: titles and bodies containing quotes or braces cannot break the query or inject anything.
- Labels and assignees are set by NODE ID, not by name, and the list replaces what is there rather than adding to it. Reading an issue first with github-search-issues gives you its current labels so you can send the full intended set.
- The transformation wraps the payload in an explicit body envelope, and that wrapper is load-bearing. A GraphQL payload with no variables would have only the root key query, which is entirely inside the envelope word set, so FlurryPORT would read it as routing metadata and deliver a null body, failing closed as the bare string transform failed with no HTTP status. Wrapping in body makes the pipe immune.
- Results arrive on the replay execution the fire created, not on the capture. Read them with get_replay_execution.
- On plans without full response bodies the stored response is a 4096-byte preview. Each entry's confirmation costs roughly 190 bytes, so batches beyond about twenty entries will have their receipt cut off. The writes still happened: a truncated receipt is a reading problem, not a delivery failure. Check ResponseBodyTruncated, and prefer batches of twenty or fewer so the receipt stays readable.
- This recipe updates and never creates, so it is repeatable by construction: sending the same batch twice produces the same end state. That is what makes an unreadable receipt recoverable. Filing new issues lives in github-create-issue precisely so that the repeatable path and the non-repeatable one stay in different pipes.
- The Accept-Encoding identity header in the target template is load-bearing. GitHub does not compress unless asked, but the firing client's own headers ride through the capture to the destination, and MCP clients typically send Accept-Encoding: br, gzip, deflate. Without the identity override the pipe asks for compression on your behalf and the stored response preview becomes unreadable binary.
- A 404 from GitHub usually means the PAT is not granted to that repository: GitHub hides existence rather than answering 403. On GraphQL the same condition arrives as a NOT_FOUND entry in the errors array with a 200 status.
- Because this pipe can change your backlog, consider turning on endpoint signing so that possession of the capture URL is not by itself the authority to edit issues.
- Safest test fire: one entry that sets a body on an issue you already own, which is visible, reversible and cannot affect anything else.
Where this fits
Related recipes
Let your AI file GitHub issues with a fine-grained PAT it never touches.
Search GitHub issues and read whole threads back, without your AI ever holding the PAT.
Create and update Azure DevOps work items in one batched pipe: priority, state, tags, and sprint included.
Query Azure DevOps work items with WIQL and read the matching ids back, without your AI ever holding the PAT.
Read fields for a batch of Azure DevOps work items by id: title, state, tags, priority, iteration.
Let your AI file Azure DevOps work items (bugs, tasks) the model never holds credentials for.