This repository contains a local dashboard that combines GitHub issues from gh
and Jira tickets from acli into one unified view. Planning dates and status are
editable inline for both sources — GitHub via gh api graphql (Projects v2) and
Jira via the REST API (curl), the latter requiring a [jira] credentials block
(see Editing dates and status).
crates/quasar: Rust backend for CLI integration, config loading, normalized APIs, and testsapps/frontend: React frontend for dashboard visualizations and filtersdocs/plans: design and implementation planning documents
A first-time, end-to-end setup. Commands use Homebrew (macOS); on Linux use your package manager or the upstream installers linked below.
| Tool | Why it's needed | Install (macOS / Homebrew) |
|---|---|---|
Rust toolchain (cargo, rustc) |
Builds and runs the backend | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh (via rustup) |
| Node.js + npm | Builds and serves the frontend | brew install node |
gh (GitHub CLI) |
Fetches GitHub issues and edits Projects v2 fields | brew install gh |
acli (Atlassian CLI) |
Fetches Jira work items | brew install atlassian/acli/acli |
mise (optional) |
Task runner for mise run dev |
brew install mise |
Verify:
cargo --version && node --version && gh --version && acli --versionGitHub — log in, then ensure the token carries the Projects v2 scope. This is
required for date/Status enrichment and inline editing; without it those queries
fail with INSUFFICIENT_SCOPES and dates silently render blank:
gh auth login # if not already logged in
gh auth refresh -s project # add the Projects v2 (read+write) scope
gh auth status # confirm scopes include 'project'Jira — acli handles its own auth (OAuth or API token) for reading:
acli jira auth login # OAuth or API token
acli jira auth status # confirm the site + accountEditing Jira dates/status is separate: it uses the Jira REST API with the
[jira]email/token block in your config (see Editing dates and status). Reading works withacliauth alone.
The backend reads ~/.config/quasar/config.toml. Create it and lock it down
(it may hold a Jira API token):
mkdir -p ~/.config/quasar
$EDITOR ~/.config/quasar/config.toml
chmod 600 ~/.config/quasar/config.tomlA minimal starting point:
github_repos = ["your-org/your-repo"]
[jira_board]
projects = ["ENG"] # your Jira project key(s)See Backend Configuration for every option, including
the optional [github_project] (date/status enrichment + editing) and [jira]
(Jira write credentials) blocks.
cd apps/frontend
npm installFrom the repo root, with mise:
mise run dev # backend (:3000) + frontend (:5173)…or without mise, in two terminals (see Launch Locally).
Then open http://localhost:5173. To try it with no live credentials, use
fixture mode: mise run dev-fixtures.
The backend reads its user-local config from:
~/.config/quasar/config.toml
If the file is missing, the service falls back to built-in defaults.
Example config:
bind_addr = "127.0.0.1:3000"
cache_ttl_secs = 30 # work-items response cache TTL (seconds)
jira_date_cache_ttl_secs = 600 # per-issue Jira Start/Target date cache TTL (seconds)
mode = "cli"
github_repos = [
"openai/quasar",
"rust-lang/rust",
"tokio-rs/tokio",
]
# Optional: the Jira site domain used to build work-item browse links.
# Defaults to https://quera.atlassian.net. Set it to match the site `acli`
# is authenticated to (and the write-path [jira].base_url below).
jira_base_url = "https://quera.atlassian.net"
# Which Jira project(s) to pull work items from. Keys compose a
# `project in (...)` clause; ordering defaults to `ORDER BY updated DESC`.
[jira_board]
projects = ["ENG"]
# Optional: pull every ticket assigned to OR created by these people, across
# all projects on the site (not just [jira_board]). Emails must be non-empty
# and contain no whitespace or double-quote characters.
[jira_people]
users = ["alice@example.com", "bob@example.com"]
# Optional: an extra raw JQL filter AND'd with the [jira_board] selection above.
# Its own `ORDER BY`, if present, becomes the query's ordering. Omit [jira_board]
# and set only this to run a fully hand-written query verbatim (escape hatch).
# jira_jql = "statusCategory != Done"
# Optional: GitHub Projects v2 board for date/status enrichment + editing.
[github_project]
owner = "your-org"
number = 18
# Optional: Jira REST credentials. Required only to edit Jira dates/status;
# omit it and Jira fields stay read-only. Token is stored in plaintext, so
# keep the file owner-only (chmod 600).
[jira]
email = "you@example.com"
token = "<atlassian-api-token>" # id.atlassian.com -> Security -> API tokens
# base_url = "https://your-site.atlassian.net" # optional, set to your siteConfig resolution order is:
- Environment variable overrides
~/.config/quasar/config.toml- Built-in defaults
Environment overrides still work for one-off runs and local debugging:
QUASAR_BINDdefault:127.0.0.1:3000QUASAR_CACHE_TTL_SECSdefault:30QUASAR_MODEvalues:cliorfixturesQUASAR_GITHUB_REPOexample:openai/quasarThis override forces a single GitHub repo, even ifgithub_reposcontains multiple entries in the config file.QUASAR_JIRA_JQLdefault:ORDER BY updated DESC. Sets the optional raw JQL filter (the one AND'd with[jira_board]), overriding anyjira_jqlin the file. Project selection via[jira_board]has no environment override.
The backend fetches GitHub data by shelling out to the gh CLI in two steps:
- Issues —
gh issue list -R <repo>pulls open issues for each slug ingithub_repos. - Planning dates — if a
[github_project]table is configured, agh api graphql(Projects v2) query enriches each issue with Start and Target dates from a project board.
Two separate config pieces identify what gets fetched:
github_repos— a list ofowner/reposlugs. This is what actually scopes the query; issues are read per-repo, and the owner/name come from splitting each slug.[github_project]— the board is selected purely by its numericnumber. There is no project name field — a board like "Scientific Software Dev" is identified only by the number in its URL (e.g.github.com/orgs/your-org/projects/18→number = 18).
Example:
github_repos = ["your-org/quasar"]
[github_project]
owner = "your-org"
number = 18
# optional, defaults shown:
start_date_field = "Start date"
target_date_field = "Target date"Notes:
- The query is repository-scoped. It walks each configured repo's issues
and matches their
projectItemsagainstnumber. Issues on the board whose repo is not listed ingithub_reposare not fetched. There is no org-level "list everything on project N" lookup. - The
ownerfield in[github_project]is required by the config parser but is not currently read at runtime — the effective owner/repo come from thegithub_reposslugs. - The same single
[github_project]is applied to all configured repos.
Jira work items come from acli jira workitem search --jql <query>. Fetching is
per project (mirroring the per-repo GitHub fan-out): the backend runs one
acli query per configured project and streams each project's results as they
resolve, so cards appear progressively and one project failing surfaces a
warning without sinking the others. The query set is composed from two config
pieces:
[jira_board]—projects = ["SSW", "ENG"]selects which Jira project(s) to pull from. Each key becomes its ownproject = KEYquery (a union across projects, fetched independently). This is the structured analog ofgithub_repos.jira_jql(optional) — a raw JQL filter AND'd into each project's query, so it narrows the results (e.g. exclude Done). A single trailingORDER BYis applied: the raw clause's ownORDER BYif it has one, otherwise the defaultORDER BY updated DESC.[jira_people](optional) —users = ["email", ...]pulls every ticket assigned to OR created by the listed people across all projects on the site (not just those in[jira_board]). Mechanically it appends one extraacliquery,(assignee in (...) OR reporter in (...)), to the per-project fan-out, streamed as its own chunk. Emails are validated (non-empty, no whitespace, no double-quote). Its results are merged with the board results and de-duplicated by issue key, so a ticket that is both in a configured project and matches a person appears once.jira_jqlis AND'd into the person query too, so set e.g.jira_jql = "statusCategory != Done"to bound it — "all tickets related to a person" can be large (a prolific reporter can have hundreds), and each fetched item still costs a per-issueviewcall for planning-date enrichment, so a large person set slows refresh.
Planning-date enrichment (per-issue, cached). acli's bulk
workitem search cannot return the Start/Target custom fields, so the backend
enriches each issue's planning dates with a separate acli workitem view call
per issue key. Those per-issue results are cached in memory, keyed by issue
key, with a configurable TTL jira_date_cache_ttl_secs (default 600
seconds). On each refresh only issues missing from the cache (or past the
TTL) are re-fetched, so subsequent refreshes are much faster than the first.
Editing a Jira Target start/Target end date through the app invalidates that
issue's cached dates immediately, so your own edits reflect right away; only
external date changes can be stale until the TTL expires. This cache is
in-memory only and is lost on restart, and it is separate from the
short-lived work-items response cache (cache_ttl_secs, default 30s).
Source fetching is concurrent: GitHub repos and Jira queries are resolved in parallel (rather than sequentially) and each is streamed to the UI as it completes, so a cold refresh takes about as long as the single slowest source instead of the sum of them all.
The browse link on each Jira card (and the ↗ original link) is built from
jira_base_url (top-level, optional, default https://quera.atlassian.net).
Set it to match the site acli is authenticated to; it should also match the
write-path [jira].base_url, which remains a separate key with the same default.
Composition examples (each line is a separate acli query):
# one project -> one query
[jira_board]
projects = ["SSW"]
# -> project = SSW ORDER BY updated DESC
# multiple projects -> one query each (streamed independently)
[jira_board]
projects = ["SSW", "ENG"]
# -> project = SSW ORDER BY updated DESC
# -> project = ENG ORDER BY updated DESC
# each project's query AND'd with an extra filter
jira_jql = "statusCategory != Done"
[jira_board]
projects = ["SSW", "ENG"]
# -> (project = SSW) AND (statusCategory != Done) ORDER BY updated DESC
# -> (project = ENG) AND (statusCategory != Done) ORDER BY updated DESC
# escape hatch: no [jira_board], raw JQL is the sole query, verbatim
jira_jql = "project = SSW AND statusCategory != Done ORDER BY updated DESC"
# [jira_people] -> one extra cross-project query, merged + deduped by key
[jira_board]
projects = ["SSW", "ENG"]
[jira_people]
users = ["alice@example.com", "bob@example.com"]
# -> project = SSW ORDER BY updated DESC
# -> project = ENG ORDER BY updated DESC
# -> (assignee in ("alice@example.com","bob@example.com") OR reporter in ("alice@example.com","bob@example.com")) ORDER BY updated DESC
# jira_jql bounds the person query too (AND'd in)
jira_jql = "statusCategory != Done"
[jira_people]
users = ["alice@example.com"]
# -> ((assignee in ("alice@example.com") OR reporter in ("alice@example.com")) AND (statusCategory != Done)) ORDER BY updated DESCTo combine boards with a filter, [jira_board] selects the project(s) (union)
and jira_jql narrows them (AND). For anything the two can't express together,
omit [jira_board] and write the whole query in jira_jql.
Clicking a work-item card's title opens an overlay with the full issue/ticket
body (rendered Markdown), the comment thread, and a metadata sidebar (status,
assignees, author, labels, priority, dates, repo/project, and a link to the
original). A work item carries a list of assignees — GitHub issues can have
several, Jira has 0 or 1 — rendered as stacked avatars on cards and in the
timeline. Detail is fetched lazily only when an item is opened, via
GET /api/work-item-detail?id=<work-item-id>, and is not cached — each open
fetches fresh from gh issue view / acli jira workitem view. The ↗ link on
each card still opens the original issue/ticket in a new tab.
GitHub work-item Start/Target dates and the Projects v2 Status (the board
single-select field, distinct from the issue open/closed state) are editable
inline from the detail overlay. Opening an item enriches the detail with the
item's current dates, Status, and the available Status options via one
gh api graphql query, so the date inputs and Status dropdown open pre-filled.
Edits issue PATCH /api/work-item-field
({ id, field: "start" | "target" | "status", value }), which resolves the
project/field/(option)/item, adds the issue to the configured board if needed,
runs an updateProjectV2ItemFieldValue (or clear) mutation, and invalidates the
work-items cache. Requires a gh token with project write scope and a
[github_project] configured (optional status_field, default "Status").
Jira Target start/Target end dates and workflow Status are also
editable inline when a [jira] credentials block is configured. The installed
acli (1.3.22) cannot set custom fields, so Jira writes go through the REST API
via curl: dates are set with PUT /rest/api/3/issue/<key>
(customfield_10022/10023), and status changes look up the matching workflow
transition (GET /rest/api/3/issue/<key>/transitions) and apply it
(POST .../transitions). Opening a Jira item enriches its detail with the
reachable transition targets so the Status dropdown is pre-filled; because Jira
status is workflow-driven it offers no blank/clear option. The same
PATCH /api/work-item-field endpoint handles both sources, keyed off the
github:/jira: id prefix.
[jira]
email = "you@example.com"
token = "<atlassian-api-token>" # id.atlassian.com → Security → API tokens
# optional, set to your Atlassian site:
base_url = "https://your-site.atlassian.net"The token is stored in plaintext in config.toml, so keep the file readable
only by you (chmod 600). Without a [jira] block, Jira fields stay read-only
and edit attempts return 409.
Assignees are also editable inline. The list of assignable candidates is fetched
lazily when an item is opened (GitHub gh api repos/<repo>/assignees; Jira
GET /rest/api/3/user/assignable/search?issueKey=<key>) on a best-effort basis
— if it can't be fetched, the assignee field renders read-only. Editing behaves
per source:
- GitHub — a checkbox list of the repo's assignable users; you can assign
several. Writes go through
gh issue edit --add-assignee/--remove-assignee(the backend diffs current vs. desired). This needs aghtoken with write access to the repo's issues; it does not require the Projects v2projectscope or a[github_project]config. - Jira — a single-select dropdown (with a
(none)option to unassign), since Jira allows exactly one assignee. Writes go through the Jira REST API (PUT /rest/api/3/issue/<key>with the assigneeaccountId, ornullto clear) and therefore require the[jira]credentials block, same as date/status editing.
Both sources use the same endpoint, PATCH /api/work-item-assignees
({ id, assignee_ids: [...] }).
Alongside the Board and Timeline views, a third People tab tracks a
specific configured person's Jira tickets, fetched on demand. Opening the
tab lists the configured [jira_people] emails in a single-select dropdown;
nothing is fetched until a person is selected (lazy). Selecting a person
fetches, via GET /api/person-work-items?user=<email>, two groups:
- Created by — tickets where
reporter = <email>. - Mentioned — a best-effort full-text proxy,
text ~ "<accountId>". Jira has no exact @mention JQL, so this matches content where the person's accountId appears (i.e. @mentions); it is not an exact match.
Details and caveats:
- The person's accountId is derived from the
reporterfield of one of their own created tickets (a singleaclisearch with--limit 1), so the page needs no[jira]REST credentials. If the person has created nothing, the accountId can't be resolved and the Mentioned section shows "mentions unavailable" — Created-by still works. - Results are deduplicated by issue key: a ticket that is both created-by and mentioned appears only under Created by.
- The optional
jira_jqlfilter is AND'd into both queries, bounding them. - The list is not date-enriched (cards show "—" for planning dates), keeping the on-demand fetch fast.
- Only preconfigured people can be queried — the endpoint rejects any
usernot in[jira_people].
This reuses the same [jira_people] list already used for the board-stream
merge (see Jira Data Fetching); that board behavior is
unchanged. A companion endpoint, GET /api/people, returns the configured
[jira_people] emails and powers the dropdown.
Start the local API server:
cargo run -p quasarRun backend tests:
cargo test -p quasar -- --nocaptureStart the backend with fixture data instead of live gh and acli calls:
QUASAR_MODE=fixtures cargo run -p quasarInstall dependencies:
cd apps/frontend
npm installRun frontend tests:
npm testBuild production assets:
npm run buildStart the development server:
npm run devIn dev mode, the frontend proxies /api/* requests to http://127.0.0.1:3000.
Implemented now:
- backend config loading from
~/.config/quasar/config.toml - GitHub fan-out across multiple configured repositories
- Jira per-project fan-out plus an optional
[jira_people]cross-project query (assignee OR reporter across all projects), merged with the board results and de-duplicated by issue key - configurable
jira_base_urlfor Jira browse links (defaulthttps://quera.atlassian.net) - fixture-backed and CLI-backed adapter paths for GitHub and Jira
- short-lived in-memory caching for API responses (
cache_ttl_secs, default 30s) - an in-memory per-issue Jira planning-date cache (from
acli workitem view), keyed by issue key with a configurable TTL (jira_date_cache_ttl_secs, default 600s); only cache misses/expired entries are re-fetched on refresh, and editing a Jira date invalidates that issue's entry immediately - concurrent GitHub + Jira source fetching, each streamed to the UI as it completes, so a cold refresh runs about as long as the slowest single source
- unified work-item rendering with explicit repo metadata in the payload
- work items carrying a list of assignees (multiple on GitHub, 0 or 1 on Jira), shown as stacked avatars on cards and the timeline
- summary cards, status chart, recent activity panel, and tests
- source-aware container and source filters plus multi-select status and assignee filters in the frontend
- item detail overlay with lazily-fetched body, comments, and metadata
- inline editing of GitHub start/target dates and board Status
- inline editing of assignees (GitHub multi-select, Jira single-select)
- a People tab that lazily fetches a configured person's Jira tickets on
demand (created-by, plus a best-effort full-text "mentioned" proxy), needing
no
[jira]credentials
The second filter is source-aware: with Source = GitHub it lists
repositories, with Source = Jira it lists projects, and with Source = All it
shows a combined list (Jira entries hinted). Its options are drawn from each
item's container (GitHub owner/repo or Jira project key), and the dashboard
cards/list update against the active container, source, status, and assignee
selections. The container and source filters are single-select, while the
status and assignee filters (shared by the board and timeline views) are
multi-select — a checkbox dropdown where selecting several values matches
items with any of them (OR). The assignee dropdown also carries an
"Unassigned" entry that matches items with no assignee.
Use two terminals.
Terminal 1, start the backend from the repo root:
cargo run -p quasarTerminal 2, start the frontend dev server:
cd apps/frontend
npm run devThen open:
http://localhost:5173
For predictable sample data, either set mode = "fixtures" in your config file
or launch the backend with:
QUASAR_MODE=fixtures cargo run -p quasarIf you use mise, you can start services from the repo root:
mise run devOr start the fixture-backed version:
mise run dev-fixturesAvailable tasks:
mise run backendmise run backend-fixturesmise run frontendmise run devmise run dev-fixtures