{
  "name": "Label new GitHub issues with OpenAI and track the cost in Vivere",
  "nodes": [
    {
      "id": "604544fd-2b42-53de-a229-a64f6abacfcc",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        -260
      ],
      "parameters": {
        "width": 560,
        "height": 1120,
        "content": "## Label new GitHub issues with OpenAI, and track what it costs\n\n**Who it's for:** maintainers who want every new issue labelled within the hour, and want to know what the labelling costs and when it stops working.\n\n**What it does:** every hour it reads the repository's open issues, takes the ones nobody has labelled yet, and asks an OpenAI model to pick labels from your list. It adds those labels on GitHub (or `needs-triage` when the model's answer fits none of them). Labelled issues are skipped next time, so a missed hour is caught up on the next run. Each run is reported to a [Vivere](https://vivere.dev/docs/n8n) heartbeat monitor with the model, tokens and cost, which alerts you (email, Slack, Discord, Telegram, ntfy or webhook) when:\n- the run fails: GitHub or OpenAI refuses a request, or a key has expired\n- a run costs more than the limit you set, say after a flood of spam issues\n- the workflow doesn't run at all: the instance was down, or someone deactivated it\n\n### Setup (about 10 minutes)\n1. Create a GitHub fine-grained token with **Issues: Read and write** on the repository and add it as a **GitHub API** credential on **Get open issues** and **Add the labels**.\n2. Add an OpenAI API key as an **OpenAI** credential on **Ask OpenAI for labels**.\n3. Sign up at [vivere.dev](https://vivere.dev) (the free plan covers 25 monitors), add a **Heartbeat** monitor with period `1 hour` and grace `15 minutes`, and optionally **Alert when a run costs more than** `$0.05`. Create an API key under **API keys** for the **Vivere** credential.\n4. In **Config**, paste the monitor's ping URL and set `owner`, `repo` and `labels` (comma separated, as they are named in the repository).\n5. Run it once by hand, check the issues and the run in Vivere, then activate it.\n\n### Customize\n- `model` takes any OpenAI chat model. Set the three prices in **Config** to that model's prices per million tokens, so the cost Vivere records is right.\n- `maxIssues` caps how many issues one run labels, which caps the cost of a run.\n- Change the instructions in the system message of **Ask OpenAI for labels**."
      }
    },
    {
      "id": "2eb7939d-1794-5dca-a143-3fc44a2b7ef1",
      "name": "Note: pick",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -160,
        -260
      ],
      "parameters": {
        "color": 7,
        "width": 1020,
        "height": 1120,
        "content": "### 1. Find unlabelled issues\n**Ping start** tells Vivere the run began. **Get open issues** lists the newest open issues; GitHub returns pull requests here too. **Pick unlabelled issues** drops pull requests and anything already labelled, and keeps at most `maxIssues`. When there is nothing to label, the run still ends with a success ping of 0 items, because a quiet hour is not a failure."
      }
    },
    {
      "id": "ada927ac-91df-54fc-a5ab-8436913009b5",
      "name": "Note: label",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        880,
        -260
      ],
      "parameters": {
        "color": 7,
        "width": 1080,
        "height": 1120,
        "content": "### 2. Label them\n**Ask OpenAI for labels** calls the Chat Completions API directly, so each answer carries its token usage; *Never Error* is on so the next step can say which issue failed. **Read the labels** keeps only labels from your list, falls back to `fallbackLabel`, and works out the run's cost from the prices in **Config**. **Add the labels** adds them without removing any already there."
      }
    },
    {
      "id": "4570fa6b-6f5f-50d5-ab52-a95542b85350",
      "name": "Note: report",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1980,
        -260
      ],
      "parameters": {
        "color": 7,
        "width": 760,
        "height": 1120,
        "content": "### 3. Report to Vivere\nExactly one closing ping per run:\n- **Ping success** sends `items`, `model`, `in`, `out`, `cache_read` and `cost`, and one line per labelled issue as the run's log\n- **Ping fail** sends what went wrong, then **Mark the run failed** stops the execution as failed, so your n8n error workflow fires too\n\nIf no ping arrives at all, Vivere alerts when the grace period runs out."
      }
    },
    {
      "id": "f4e92b4d-7b5e-5886-a81d-563be0269c02",
      "name": "Every hour",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -120,
        140
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours"
            }
          ]
        }
      }
    },
    {
      "id": "7d5c2872-2745-5adb-a5ac-39df67ee6a7a",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        80,
        140
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "83a3df96-37a7-50bd-aa96-4bf1fa74c109",
              "name": "pingUrl",
              "value": "https://vivere.dev/p/YOUR-MONITOR-ID",
              "type": "string"
            },
            {
              "id": "663174ea-34c5-53cb-acb4-f44412ca00e2",
              "name": "githubApiUrl",
              "value": "https://api.github.com",
              "type": "string"
            },
            {
              "id": "7daade14-306e-57eb-a360-d5b9452e873c",
              "name": "owner",
              "value": "your-github-user",
              "type": "string"
            },
            {
              "id": "e448fa22-d093-520b-a457-482d8741948c",
              "name": "repo",
              "value": "your-repo",
              "type": "string"
            },
            {
              "id": "4128a8db-906a-52d2-ad6e-14f3c8257a3f",
              "name": "labels",
              "value": "bug, enhancement, question, documentation",
              "type": "string"
            },
            {
              "id": "b9480d63-8235-5da6-ab98-8681246c55c3",
              "name": "fallbackLabel",
              "value": "needs-triage",
              "type": "string"
            },
            {
              "id": "33ff1a22-bef2-52e7-ad87-d45dee07622a",
              "name": "maxIssues",
              "value": 20,
              "type": "number"
            },
            {
              "id": "4207f7e2-e7b2-5ad2-a182-8b9117e3515b",
              "name": "openAiUrl",
              "value": "https://api.openai.com/v1",
              "type": "string"
            },
            {
              "id": "5398eb74-5f35-5e9d-a27d-97f860f49965",
              "name": "model",
              "value": "gpt-5.4-nano",
              "type": "string"
            },
            {
              "id": "bd0b3543-04ba-5b28-a0a8-27c6721c2580",
              "name": "priceInputPerMillion",
              "value": 0.2,
              "type": "number"
            },
            {
              "id": "0832e9ac-da88-54bf-a949-7362ea96a6ff",
              "name": "priceCachedInputPerMillion",
              "value": 0.02,
              "type": "number"
            },
            {
              "id": "ff8710e8-739f-593b-afe9-40d37f0ac439",
              "name": "priceOutputPerMillion",
              "value": 1.25,
              "type": "number"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "191f9287-f079-5f99-ad0e-5f2de5250e8e",
      "name": "Ping start",
      "type": "n8n-nodes-vivere.vivere",
      "typeVersion": 1,
      "position": [
        280,
        140
      ],
      "parameters": {
        "resource": "ping",
        "monitor": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Config').first().json.pingUrl }}"
        },
        "operation": "start"
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "446af9d1-a310-5e0c-a79a-e86467aa6eb5",
      "name": "Get open issues",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        480,
        140
      ],
      "parameters": {
        "url": "={{ $('Config').first().json.githubApiUrl }}/repos/{{ $('Config').first().json.owner }}/{{ $('Config').first().json.repo }}/issues",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "state",
              "value": "open"
            },
            {
              "name": "sort",
              "value": "created"
            },
            {
              "name": "direction",
              "value": "desc"
            },
            {
              "name": "per_page",
              "value": "100"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/vnd.github+json"
            },
            {
              "name": "X-GitHub-Api-Version",
              "value": "2022-11-28"
            }
          ]
        },
        "options": {}
      },
      "alwaysOutputData": true,
      "onError": "continueErrorOutput"
    },
    {
      "id": "49e76e93-9f33-535d-ac4e-254caf38ed77",
      "name": "Pick unlabelled issues",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        120
      ],
      "parameters": {
        "jsCode": "// GitHub lists pull requests as issues too; skip them and anything already labelled.\nconst { maxIssues } = $('Config').first().json;\nconst issues = $input\n  .all()\n  .map((i) => i.json)\n  .filter((i) => i && i.number && !i.pull_request && !(i.labels || []).length)\n  .slice(0, maxIssues);\nif (!issues.length) return [{ json: { none: true } }];\nreturn issues.map((i) => ({\n  json: { number: i.number, title: i.title, body: String(i.body || '').slice(0, 4000) },\n}));\n"
      }
    },
    {
      "id": "2f97850a-401f-589f-a957-cc0bb4b391f4",
      "name": "Anything to label?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        140
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "6f63d7a1-3fdf-52ec-a9f7-6e620e8f2d19",
              "leftValue": "={{ $json.number }}",
              "rightValue": "",
              "operator": {
                "type": "number",
                "operation": "exists",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "6cb2e4d0-0c8c-5886-a341-c0c52c7b243a",
      "name": "Ask OpenAI for labels",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1160,
        40
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config').first().json.openAiUrl }}/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  model: $('Config').first().json.model,\n  reasoning_effort: 'low',\n  response_format: { type: 'json_object' },\n  messages: [\n    {\n      role: 'system',\n      content: 'You label GitHub issues. Choose one or more labels from this list and no others: ' + $('Config').first().json.labels + '. Use the label names exactly as written. Reply with JSON only, in the form {\"labels\": [\"...\"], \"reason\": \"one short sentence\"}.'\n    },\n    { role: 'user', content: 'Title: ' + $json.title + '\\n\\n' + $json.body }\n  ]\n}) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "ce13b246-e4fb-5c70-a820-cfeaf9659915",
      "name": "Read the labels",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1380,
        40
      ],
      "parameters": {
        "jsCode": "// Checks every answer, keeps only labels from the list, and adds up tokens and cost.\nconst cfg = $('Config').first().json;\nconst allowed = new Map(\n  String(cfg.labels).split(',').map((l) => l.trim()).filter(Boolean).map((l) => [l.toLowerCase(), l]),\n);\nconst issues = $('Anything to label?').all();\nlet input = 0, cached = 0, output = 0, model = cfg.model;\n\nconst labelled = $input.all().map((item, n) => {\n  const issue = issues[n].json;\n  const { statusCode, body } = item.json;\n  if (statusCode !== 200) {\n    const why = (body && body.error && body.error.message) || JSON.stringify(body).slice(0, 300);\n    throw new Error('OpenAI answered ' + statusCode + ' for issue #' + issue.number + ': ' + why);\n  }\n  const u = body.usage || {};\n  input += u.prompt_tokens || 0;\n  output += u.completion_tokens || 0;\n  cached += (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0;\n  model = body.model || model;\n  let picked = [];\n  try {\n    picked = JSON.parse(body.choices[0].message.content).labels || [];\n  } catch (e) {}\n  const labels = [...new Set(picked.map((l) => allowed.get(String(l).trim().toLowerCase())).filter(Boolean))];\n  return { number: issue.number, title: issue.title, labels: labels.length ? labels : [cfg.fallbackLabel] };\n});\n\n// OpenAI counts cached tokens inside prompt_tokens; Vivere wants them apart.\nconst cost =\n  ((input - cached) * cfg.priceInputPerMillion + cached * cfg.priceCachedInputPerMillion + output * cfg.priceOutputPerMillion) / 1e6;\nconst usage = { model, input: input - cached, cached, output, cost: Math.round(cost * 1e6) / 1e6 };\nreturn labelled.map((l) => ({ json: { ...l, usage } }));\n"
      },
      "onError": "continueErrorOutput"
    },
    {
      "id": "66682590-8688-51cf-a978-44583fe7e085",
      "name": "Add the labels",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1600,
        20
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config').first().json.githubApiUrl }}/repos/{{ $('Config').first().json.owner }}/{{ $('Config').first().json.repo }}/issues/{{ $json.number }}/labels",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/vnd.github+json"
            },
            {
              "name": "X-GitHub-Api-Version",
              "value": "2022-11-28"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ labels: $json.labels }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "a2b33785-8f26-517a-a27f-5938f97a3120",
      "name": "Count the results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        20
      ],
      "parameters": {
        "jsCode": "// Fails the run if GitHub refused any labels; otherwise sums it up for the ping.\nconst labelled = $('Read the labels').all().map((i) => i.json);\nconst failed = $input\n  .all()\n  .map((i, n) => ({ res: i.json, issue: labelled[n] }))\n  .filter((x) => x.res.statusCode >= 300);\nif (failed.length) {\n  const f = failed[0];\n  const why = (f.res.body && f.res.body.message) || JSON.stringify(f.res.body).slice(0, 200);\n  throw new Error('GitHub refused the labels on ' + failed.length + ' of ' + labelled.length + ' issues; #' + f.issue.number + ' answered ' + f.res.statusCode + ': ' + why);\n}\nconst u = labelled[0].usage;\nreturn [{\n  json: {\n    count: labelled.length,\n    ...u,\n    log: labelled.map((l) => '#' + l.number + ' ' + l.labels.join(', ') + ': ' + l.title).join('\\n'),\n  },\n}];\n"
      },
      "onError": "continueErrorOutput"
    },
    {
      "id": "ecc87e4f-4e11-590e-af2f-d5d82985b8be",
      "name": "Ping success",
      "type": "n8n-nodes-vivere.vivere",
      "typeVersion": 1,
      "position": [
        2080,
        0
      ],
      "parameters": {
        "resource": "ping",
        "monitor": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Config').first().json.pingUrl }}"
        },
        "operation": "success",
        "itemCount": "custom",
        "customCount": "={{ $json.count }}",
        "usage": {
          "model": "={{ $json.model }}",
          "inputTokens": "={{ $json.input }}",
          "outputTokens": "={{ $json.output }}",
          "cacheRead": "={{ $json.cached }}",
          "cost": "={{ $json.cost }}"
        },
        "successOptions": {
          "log": "={{ $json.log }}"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "f5ed5b22-0d11-52f5-a6e6-5f04fbf49f5a",
      "name": "Ping success (nothing new)",
      "type": "n8n-nodes-vivere.vivere",
      "typeVersion": 1,
      "position": [
        2080,
        220
      ],
      "parameters": {
        "resource": "ping",
        "monitor": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Config').first().json.pingUrl }}"
        },
        "operation": "success",
        "itemCount": "custom",
        "customCount": 0,
        "successOptions": {
          "log": "No unlabelled issues"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "afbce7d7-ed33-5f52-a22f-1cf08fbbaf98",
      "name": "Describe the error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        500
      ],
      "parameters": {
        "jsCode": "// Turns whatever failed into one line for Vivere and the execution log.\nconst e = $json.error ?? $json;\nconst text = (typeof e === 'string' ? e : e.message || e.description || JSON.stringify(e)).replace(/ \\[line \\d+\\]$/, '');\nreturn [{ json: { message: `Labelling failed: ${text}`.slice(0, 1000) } }];\n"
      }
    },
    {
      "id": "238a405e-9d98-596d-aca0-db747656b95f",
      "name": "Ping fail",
      "type": "n8n-nodes-vivere.vivere",
      "typeVersion": 1,
      "position": [
        2080,
        500
      ],
      "parameters": {
        "resource": "ping",
        "monitor": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Config').first().json.pingUrl }}"
        },
        "operation": "fail",
        "message": "={{ $json.message }}"
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "5336b7f1-3a32-5f48-a2d8-d50467c66259",
      "name": "Mark the run failed",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [
        2300,
        500
      ],
      "parameters": {
        "errorMessage": "={{ $json.message }}"
      }
    }
  ],
  "pinData": {},
  "connections": {
    "Every hour": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Ping start",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ping start": {
      "main": [
        [
          {
            "node": "Get open issues",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get open issues": {
      "main": [
        [
          {
            "node": "Pick unlabelled issues",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Describe the error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick unlabelled issues": {
      "main": [
        [
          {
            "node": "Anything to label?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything to label?": {
      "main": [
        [
          {
            "node": "Ask OpenAI for labels",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ping success (nothing new)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask OpenAI for labels": {
      "main": [
        [
          {
            "node": "Read the labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the labels": {
      "main": [
        [
          {
            "node": "Add the labels",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Describe the error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add the labels": {
      "main": [
        [
          {
            "node": "Count the results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count the results": {
      "main": [
        [
          {
            "node": "Ping success",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Describe the error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Describe the error": {
      "main": [
        [
          {
            "node": "Ping fail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ping fail": {
      "main": [
        [
          {
            "node": "Mark the run failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
