← Back to all posts

The Issue Tracker My Task Manager Could Not See

Title card showing a checklist where four rows are ticked and one sits unticked and greyed out, the issue the importer never saw

TL;DR: bugwarrior pulls issues from GitHub, GitLab, Jira, Bugzilla and a dozen other trackers into taskwarrior. It has no service for Gitea or Forgejo, so the issues I actually work from were invisible to the system I use to decide what to work on. I wrote the importer it lacks. Then a Codeberg outage returned 502 for every repository, my reconcile pass read eleven empty results as “no open issues”, and it closed nineteen tasks in a single run. The fix is one line of principle: only a source that answered can prove something is gone.


The gap

I keep work in taskwarrior. Issues arrive there through bugwarrior, which knows how to talk to a long list of trackers: GitHub, GitLab, Jira, Bugzilla, Trac, Redmine, Phabricator, Pagure, Teamlab, Versionone, and more.

It does not know how to talk to Gitea, and therefore not to Forgejo, and therefore not to Codeberg. Version 2.1.0 is the newest upstream release. There is no service to configure, no plugin to install, and no open pull request waiting to change that.

Bugwarrior does ship a gitbug service, and for a while I thought that was the answer. It is not. git-bug stores issues as git refs inside the repository - a genuinely interesting model, and completely unrelated to the issue tracker your collaborators open in a browser. The two things share a name and nothing else.

I ended up running both, because they solve different halves. git-bug keeps issues as refs travelling with the repo, which is excellent for notes to myself and invisible in Codeberg’s Issues tab. The native tracker is where anybody else writes. Only the second one needed an importer.

So the situation was this: every tracker I do not use was integrated, and the one I use every day was absent. Not degraded, not partially supported - absent. My task list was confidently telling me what to work on while being structurally unable to see most of the work.

The obvious objection is that Forgejo has perfectly good command-line tools, and I have four of them: tea, forgejo-cli, fjo, and tea-dash, the Gitea equivalent of gh-dash. The last one is not in nixpkgs at all - twelve stars upstream - so it is built from an overlay in my config, which tells you roughly how thick the ecosystem around this tracker is.

They all answer the question “what issues exist”. None of them answers the question this is actually about, which is “what should I do next, across every source of work I have”. That question has exactly one answer on my machine and it is task next. A CLI that lists issues in a browser-shaped way is a second place to look, and a second place to look is the thing I was trying to remove.

Two hundred lines instead of a service

The correct fix is a bugwarrior service, upstream, that everyone can use. I did not write that, and I want to be straightforward about why: a service has to satisfy bugwarrior’s plugin contract, cover authentication modes I do not use, and be maintained against a project whose last release predates the problem. What I needed was for my own issues to show up this week.

So the importer is a standalone script. It:

  • walks ~/projects and finds every clone whose origin points at codeberg.org, so repositories are discovered from git remotes rather than a hardcoded list - a new project is picked up by cloning it, not by editing config;
  • reads a token from ~/.config/codeberg/token, mode 0600;
  • fetches open issues per repository from the Forgejo API;
  • upserts them into taskwarrior keyed on a custom attribute, cbissue, so it can find its own tasks again and never touches anything it did not create.

That last point is what makes it safe to run unattended every few minutes from a launchd agent. The script owns exactly the tasks carrying its own UDA and is blind to everything else in the database.

codeberg-tasks
# added: 3  closed: 1

It lives in my Nix configuration as modules/shared/codeberg-tasks.py, read into a wrapper with builtins.readFile so the script and the PATH it needs are declared in the same place:

".local/bin/codeberg-tasks".source =
  pkgs.writeScript "codeberg-tasks" ''
    os.environ["PATH"] = "${pkgs.taskwarrior2}/bin:${pkgs.git}/bin:" + os.environ.get("PATH", "")
    ${builtins.readFile ./codeberg-tasks.py}
  '';

The declarative trap

The first thing that broke had nothing to do with Codeberg.

Taskwarrior custom attributes have to be declared before use. The obvious way to do that is task config uda.cbissue.type string, which writes to ~/.taskrc. My .taskrc is generated by home-manager, which means it is a symlink into the Nix store, which is read-only.

Taskwarrior’s write fails. It does not say so. The command returns, the attribute is not registered, and the next import quietly folds cbissue:owner/repo#12 into the description of the task instead of into a field. You end up with a task list where every entry has a mangled title and no way to reconcile anything, and nothing anywhere told you why.

And the consequence is worse than a cosmetic one. The dedupe filter is cbissue.any:, which matches on the field. With the field never registered, it matches nothing, so every run re-adds every issue as a duplicate.

The fix is to declare the UDAs in the Nix file that generates .taskrc, alongside everything else, with the reason written next to them so the next person does not undo it:

# --- UDAs for codeberg-tasks (Forgejo importer).
# These MUST be declared here. `task config uda...` cannot add them at
# runtime because this file is a read-only nix store symlink - the write
# fails silently and taskwarrior then folds "cbissue:..." into the task
# *description* instead, so `cbissue.any:` matches nothing and every run
# re-adds every issue as a duplicate.
uda.cbissue.type=string
uda.cbissue.label=Codeberg Issue
uda.cburl.type=string
uda.cburl.label=Codeberg URL

The lesson generalises past taskwarrior: any tool that expects to write to its own config will fail silently under a declarative setup, and the failure surfaces later, somewhere unrelated, looking like a different bug.

There is a second one of these in the same corner of my config, and it is worth naming because it is the same shape. taskwarrior is pinned to 2.x on purpose. bugwarrior writes through the taskw library, which produces 2.x .data files; taskwarrior 3 stores everything in taskchampion.sqlite3 and reports a cheerful “No matches” for every task bugwarrior ever imported. Nothing errors. The data is there. The reader simply cannot see it. Upgrading a pinned version is a one-line diff and would have silently emptied my task list.

There is a third edge in the same family, and it is the one I would most easily walk into again. Packages built from my overlays/ directory are pinned by hash inside those files, and nix flake update does not touch them - there is a separate nix run .#update-overlays for that. So the declarative setup gives you two different staleness clocks running at once: inputs that move when you update the flake, and overlay-built packages that move only when you remember they exist. tea-dash sits in the second group. It is the same trade as pinning a container image by digest: immutability is the feature, and rot is the bill for it.

If you run home-manager, all three of these are worth internalising as a class of problem rather than one-offs.

The outage that closed nineteen tasks

The importer does two passes. It adds issues it has not seen, and it reconciles: any task it owns that is no longer in the fetched set must have been closed upstream, so it closes it locally too.

That reconcile pass has an assumption buried in it, and I did not notice the assumption until it cost me.

On 27 August, Codeberg had a bad afternoon. Every API request returned 502. My script did what it was written to do: it skipped repositories whose issue list failed to fetch, then ran the reconcile pass against the results it had - which, for all eleven repositories, was nothing.

An empty result is indistinguishable from “this repository has no open issues” if you do not track why it is empty. So the script concluded that every issue in every repository had been closed, and marked nineteen tasks done in one run.

Nothing was lost - taskwarrior keeps completed tasks and task undo exists - but for a few hours my task list was quietly, confidently wrong, which is worse than being empty. A tool that shows nothing makes you suspicious. A tool that shows a clean slate makes you relax.

The fix is small and the principle behind it is not:

# Repos whose issue list could not be fetched. Their tasks must be left
# alone below: an empty result from a failed request is indistinguishable
# from "no open issues", and treating it as the latter closes every task
# for that repo.
failed = set()
...
# Only a repo we actually heard back from can prove an issue is gone.
if key.rsplit("#", 1)[0] in failed:
    skipped += 1
    continue

The commit that carries it says the thing plainly enough that I will not improve on it: “A repo whose issue list failed to fetch was skipped, but the reconcile pass then closed every pending task missing from the (empty) result - an error response was indistinguishable from ‘no open issues’.”

Two changes, really. Absence of evidence stopped being evidence of absence - a repository that did not answer no longer gets a vote on whether its issues still exist. And the run now exits non-zero when anything failed, so the launchd log records a failed sync instead of a clean one that happened to close nothing.

That second half matters as much as the first. The original script was not silent about the outage; it printed that it had skipped repositories. But it exited zero, so as far as launchd was concerned it was a successful run, and successful runs do not get read.

What this says about integration scripts

Every sync script that reconciles two systems has this shape, and most of them have this bug. You fetch a remote state, compare it to a local state, and act on the difference. The moment the fetch can fail partially, the difference is a lie, and the direction of the lie is always the destructive one: things look deleted, not created.

Three rules I would now apply to anything of this kind:

Track which sources answered, not just what they returned. An empty list and a failed request are different values and should never collapse into the same variable.

Make deletion require positive evidence. Adding on incomplete data is recoverable. Removing on incomplete data is how you get a clean-looking, wrong result.

Exit non-zero when part of the job failed, even if the rest succeeded. Unattended automation is only as good as its worst run, and you find out about the worst run through the exit code or not at all.

None of this is novel. It is the same reasoning behind not deleting local files when a remote listing fails, which every backup tool learned the hard way decades ago. I just had to rediscover it on a Thursday afternoon with a task list that suddenly looked finished.

Where this sits in the rest of the setup

None of the above is worth much on its own. It matters because of where the output lands.

The importer is not something I run. A launchd agent called bugsync fires every five minutes and does three things in order: fetches and pushes refs/bugs/* for git-bug, runs bugwarrior pull for the trackers bugwarrior does understand, then runs codeberg-tasks for the one it does not. Three sources, one destination, no tab to keep open.

Diagram: three sync sources converging on taskwarrior and a single task next pane, with the two silent failures listed underneath

That destination is a pane. sesh has an inbox session defined in Nix - neomutt on the left, khal top right, taskwarrior bottom right, calendar and tasks redrawing every sixty seconds. It is one keystroke away from anywhere: prefix T opens the sesh picker, or sesh connect inbox if I already know where I am going.

The sesh inbox session in full screen: neomutt on the left, khal top right, and below it the taskwarrior list with Codeberg issues tagged codeberg. Senders, event titles and task descriptions are blurred.

A detail from that pane that cost an evening: the refresh loop is a plain clear; cmd; sleep, not watch(1). watch -c silently strips 24-bit escape sequences, which drops khal’s colours to plain SGR and makes one pane look wrong next to the other two.

Which is the same reason the terminal palette is pinned to ANSI slots 0 to 15 rather than hex: Ghostty maps those to exact Catppuccin Macchiato values, so neomutt, khal and taskwarrior agree with each other and with the background. The dark theme on the site you are reading is the same palette. That is not a coincidence, it is the one design decision I made once and then stopped re-making.

The rest of the bracket is the same shape. wt turns branches into directories and sesh turns those directories into sessions. The dashboard window in those sessions used to be gh-dash; it is tea-dash now, because leaving GitHub meant the dashboard had to leave with it. Same shape, same keystroke, a tool with twelve stars instead of nine thousand. pass holds the canonical copy of the token this importer reads, though the runtime read stays on a plain 0600 file on purpose - a launchd agent running unattended should not depend on an unlocked gpg-agent. Everything is declared in one flake, which is what made the failure in this post possible and also what made it fixable in one place.

The whole arrangement has exactly one job: make task next the only question I have to ask. An issue tracker my task manager could not see was not a missing integration. It was a second place to look, and a second place to look is how you end up with neither.