ContribKanban now tracks GitLab work items
Drupal projects are moving their issues from Drupal.org to GitLab work items, and ContribKanban couldn't see them. I rebuilt it so that one board tracks both.
ContribKanban read issues from Drupal.org's API, where every issue is a node. When a project moves to GitLab, its new issues are work items, not nodes, so the API stops returning them. Every month, more boards went quiet. Tag boards were the worst. A tag like "Novice" spans migrated and unmigrated projects, and the board only saw the unmigrated half.
Rosetta is a good example of work that spans both. Dries called it out in his DrupalCon Rotterdam Driesnote and wrote it up in One description, inside and outside Drupal. The goal is for Drupal and outside software to work from shared descriptions of a module's data and operations. Core issues, like allowing schema references in SDC prop schemas, are on Drupal.org. Tool API and MCP track theirs as GitLab work items on git.drupalcode.org. Neither system searches the other.
Fixing that meant rewriting how boards load issues. While I was in there, I rebuilt the rest of the app to make it easier to maintain. ContribKanban has been around since 2015, and this is its fourth architecture. It launched as a MEAN stack app on Parse.com. In 2017, it became a progressively decoupled Drupal 8 site. The 2020 v3 update put a React frontend in front of Drupal. That came at the height of Drupal's API-First push, and I wanted to dogfood those modules. Every layer of that stack needed its own upkeep.
Drupal pages, one React island
The board is the only interactive part of ContribKanban. It loads issues from two APIs, sorts them into status columns, and filters them by type and priority without a page reload. Everything else is a form or a list.
So Drupal renders every page, and React renders only the board. That is close to the 2017 version, where the board template was a single <div id="Board"> and Drupal passed each list's configuration to drupalSettings. Drupal handles accounts, forms, listings, access, and layout, which is what it's good at. The forgot password page, a React stub that said "In progress" for years, now just works because it is Drupal core's.
The board-shell component
The new contribkanban theme keeps its markup in single directory components. The board page is the board-shell component. Drupal builds the board's configuration in PHP and renders it into a data attribute, with an empty column per list so the page has its layout before any JavaScript runs:
{# The island replaces these columns. #}
<div class="board-shell__columns" data-board="{{ config|json_encode }}">
{% for list in config.lists %}
<section class="board-column">
<h2 class="board-column__header board-column__header--{{ list.tone }}">{{ list.title }}</h2>
<p class="board-column__status">{{ 'Loading issues…'|t }}</p>
</section>
{% endfor %}
</div>The board's configuration is right there in the page, so the island never calls back to Drupal.
The island's entry point reads that attribute and mounts:
import { createRoot } from "react-dom/client";
import Board from "./Board";
for (const element of document.querySelectorAll("[data-board]")) {
const config = JSON.parse(element.dataset.board);
const filtersSlot = element.closest(".board-shell")?.querySelector("[data-board-filters]") ?? null;
createRoot(element).render(<Board config={config} filtersSlot={filtersSlot} />);
}Building into the component
Vite builds the island in library mode, as an IIFE, straight into the component's directory. The file is named board-shell.js, so the SDC system attaches it through the component's own library. There's no separate frontend deploy target and no libraries.yml entry to keep in sync:
export default defineConfig(({ command }) => ({
plugins: [react()],
publicDir: false,
// Library mode leaves process.env alone, and React reads NODE_ENV.
define: command === 'build' ? { 'process.env.NODE_ENV': JSON.stringify('production') } : {},
build: {
outDir: '../web/themes/custom/contribkanban/components/board-shell',
// The output directory holds the component's other files.
emptyOutDir: false,
lib: {
entry: 'src/island/main.jsx',
formats: ['iife'],
name: 'ContribKanbanBoard',
fileName: () => 'board-shell.js',
},
},
}));The define is there because of a gotcha. In library mode, Vite leaves process.env.NODE_ENV alone. It assumes whatever bundles the library later will replace it. Nothing bundles this one, so the built file referenced process, which doesn't exist in the browser. I only set it for build, so Vitest still gets React's development build and its warnings.
Git ignores the built file. The CLI image builds it in a Node stage and copies it in:
FROM node:24 AS island
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend ./
RUN npm run build
FROM uselagoon/php-8.4-cli-drupal
# ...
COPY --from=island /app/web/themes/custom/contribkanban/components/board-shell/board-shell.js /app/web/themes/custom/contribkanban/components/board-shell/Cards and columns are plain elements styled by the theme's CSS custom properties. The island's package.json dependencies are only react and react-dom.
Why the browser still fetches issues
I considered having Drupal fetch issues and render the whole board in Twig. That puts every viewer behind production's single GitLab rate limit, so every board would need a server-side cache and a way to invalidate it. Fetching in the browser gives each viewer their own rate limit, and tag boards need no cache.
Boards in the GitLab era
Tag boards
Drupal.org issue tags became GitLab labels with the same name. The project group's issues endpoint filters by label, works without a token, and sends CORS headers:
export function fetchLabeledWorkItems(label, onPage, signal) {
const params = new URLSearchParams({ labels: label, state: "opened", per_page: "100" });
return fetchAllPages(`${GITLAB_API_URL}/groups/project/issues?${params}`, onPage, signal);
}A tag board now loads the label's work items once, sorts them into columns by their state:: label, and merges them with Drupal.org's results. The Rosetta board pulls its label from Tool API, Tool Belt, AI Context, and the MCP server tool bridge in one view.
When a project moves, Drupal.org keeps its old issues as read-only copies, and the API still returns them for the tag. Their status is whatever it was on moving day. For projects that moved, the board skips Drupal.org's results and uses the GitLab work items instead.
Issue collections
Tags only cover issues someone tagged. For anything else, there are issue collections, added in the 2019 update: you paste in the issues you want, from any project. For example, the upstream issues your site carries patches for.
Collections used to hold only Drupal.org node IDs, and GitLab adds two kinds of issues they need to handle. A migrated issue keeps its node ID as its GitLab IID, and its Drupal.org URL names the project. For any issue from a moved project, the island fetches the work item by IID instead of trusting Drupal.org's frozen copy:
GET /api/v4/projects/project%2F<machine_name>/issues?iids[]=<nid>&state=allA work item created on GitLab after the move has no Drupal.org node. Its IID is only unique within its project, and new IIDs overlap unrelated Drupal.org nodes: MCP's work item 3587791 shares its number with a Thunder issue on Drupal.org. A node ID can't identify it, so the collection stores the project path with the IID. Paste the work item's URL and the form keeps both:
$patterns = [
'~^https?://git\.drupalcode\.org/(project/[a-z0-9_]+)/-/(?:issues|work_items)/(\d+)(?:[/?#]|$)~',
'~^(project/[a-z0-9_]+)#(\d+)$~',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $line, $matches) === 1) {
// Stored as "project/mcp#3587791".
return $matches[1] . '#' . $matches[2];
}
}The island groups them by project and fetches each project's work items in one request, alongside the Drupal.org issues. One collection can now hold Core's SDC issue alongside Tool API and MCP work items, each in its status column.
Issue roulette and embedded boards
With Drupal rendering every page and one island rendering the board, new features no longer needed a frontend release and an API endpoint.
Issue roulette
Fons Vandamme (f0ns) opened an issue after a walk: "I can't pick myself, but I do want to work on something." Then he built it. The issue roulette rolls a strip of open issues for a tag and lands on one. You get three re-rolls.
The roulette is a second mount point in the same island. Drupal renders an issue-roulette component with the tag boards' tags in data-roulette, and the entry point mounts <Roulette> on it the same way it mounts <Board> on data-board. It loads issues with the board's hooks, useListIssues for Drupal.org and useWorkItems for GitLab, and draws them with the board's Card.
So a tag's roulette pulls from both systems and skips frozen copies of moved projects, as a tag board does. Only Active, Needs work, and Needs review issues go on the strip, and assigned issues stay off it, since someone already works on those.
Tag boards and project boards link to the roulette with a die button. The link carries the board's filters, so rolling from a board filtered to bugs in one milestone only rolls for those bugs.
Fons also reworked the theme while they were at it: flat, bordered surfaces, restyled lanes and filters, and a header menu that collapses behind a button on small screens.
Embedding a board
A board can now live in an iframe on another site, like an initiative's landing page. /board/{machine_name}/embed reuses the board's full view mode, so it's the same board-shell component. A page template for the route renders only the main content, without the header or footer:
entity.board.embed:
path: '/board/{board}/embed'
defaults:
_entity_view: 'board.full'
_title_callback: '\Drupal\Core\Entity\Controller\EntityController::title'
requirements:
_entity_access: 'board.view'Core sends X-Frame-Options: SAMEORIGIN on every response, so browsers refuse to frame the page on any other site. An event subscriber removes the header on the embed route. It runs at priority -10, after core's FinishResponseSubscriber adds the header:
public function onResponse(ResponseEvent $event): void {
if ($event->isMainRequest() && $this->routeMatch->getRouteName() === 'entity.board.embed') {
$event->getResponse()->headers->remove('X-Frame-Options');
}
}
public static function getSubscribedEvents(): array {
return [KernelEvents::RESPONSE => ['onResponse', -10]];
}Dropping the header is safe here because the embed has no forms or links that act for the viewer. Every other page keeps it.
Each board has an Embed button that copies the iframe. The board's current filters go into the query string, and the embed applies them without showing the filter bar:
<iframe title="Tool API" src="https://contribkanban.com/board/tool/embed?milestone=1.0.0-rc1" width="100%" height="600"></iframe>What's next
Core component boards stay on Drupal.org until Core's issues move. When they do, a component will probably become a label on one project, making it a project board with a label filter.
ContribKanban spent six years as a decoupled app. Now it's a Drupal site again, and the only JavaScript left is what has to be JavaScript. If you're working on Rosetta, start with its board or roll for an issue. If your sprint or initiative tracks work with an issue tag, make it a board and embed it on your initiative's page.