Skip to main content

teamtrack

A team request tracker that runs on your laptop and stores everything in your own private GitHub repos. No database, no server to host, no third-party service, no cost.

pip install teamtrack-tt[server]
tt

tt starts a local server on 127.0.0.1, opens your browser, and asks you to sign in with GitHub. You never create an account, never paste a token, and never run git.

Only connecting an editor to an existing zeb instance, not running tt itself? A bare pip install teamtrack-tt is enough — zeb-mcp, zeb-progress-hook and zeb-mcp-connect are plain standard-library scripts and need nothing from [server]. See Connect MCP in the app, or MCP-BRIDGE.md.


How it works

Piece Choice Why
Auth OAuth device flow No PAT to paste, no client secret to leak. The client ID is public by design.
Token OS keychain via keyring Never on disk in plaintext, never sent to the browser.
Writes GitHub Contents API No git binary, no clone, no working copy.
Projects One private repo each, named tt-<project> The repo is the boundary GitHub enforces.
Teams A folder inside that repo Nothing after the first repo creates another. Grouping the app enforces; GitHub still grants access per repo.
Concurrency Append-only writes Every write creates a new file. Two people filing at the same moment touch different paths and cannot conflict.

The browser only ever talks to localhost. This Python process makes every GitHub call.

Storage layout

teams/data-platform/team.json                 members + manager login digest
teams/data-platform/components.json           that team's dependency map
teams/data-platform/data/requests/REQ-20260803-1420-A7F3.json
teams/data-platform/data/events/REQ-20260803-1420-A7F3/
    20260803T145500-acknowledged.json         a status change is a new file
    20260803T151200-comment.json              so is a comment
    20260804T091200-resolved.json
teams/data-platform/data/archive/2026-07.parquet   cold archive, `tt compact`

A manager creates a project and gets a private repo named tt-<project>, plus a project login: the project name is the username, and the password they set is stored only as a digest. Pasting an existing repo link into the create form uses that repo as-is instead of making a new one — any of https://github.com/owner/repo, the .git or SSH form, a deep link, or plain owner/repo. It needs admin on that repo, refuses one that is already a project, and tags it with the teamtrack topic so it is still listed when its name lacks the prefix. If the link points at a repo that does not exist, it is created — but only on your own account. Requests land at the repo root. Team folders (teams/<slug>/) remain available for subdividing a project but stay out of the default path. Requests filed without a team sit at the repo root (data/requests/…), offered in the UI as (shared root).

Status is not a field on the request. It is derived by replaying the event files, which is what lets concurrent writers never step on each other. Request ids (REQ-<date>-<time>-<4 random>) are unique and sortable with zero coordination — deliberately not sequential, because a counter would need shared mutable state.


Setup

Nothing, for the common case. The package ships a working OAuth client ID (Ov23licr0i2DMXOl9CDq) with device flow enabled, so pip install teamtrack-tt[server] && tt signs in as-is. Device flow issues no client secret, which is why that ID is safe to publish — it identifies the app, it does not authorise anything on its own.

To point it at your own OAuth App instead (Settings → Developer settings → OAuth Apps → New, then enable device flow):

export TT_CLIENT_ID=Ov23li...

Publishing

python -m build && twine upload dist/*

The published package is public and holds no secrets — no token, no password, no client secret. Each teammate needs a GitHub account and has to accept the repo invitation.


Hosting it for your team

tt on a laptop and teamtrack behind a hostname are the same code with two different threat models. Setting TT_HOSTED=1 switches models; everything that differs between them lives in teamtrack/hosting.py.

laptop hosted
Who the caller is the one person at the keyboard whoever the Databricks proxy names, per request
Credentials this machine's OS keychain environment variables, nothing on disk
keyring the [laptop] extra not installed at all
Manager password the published default is fine refuses to start with it
Jira per machine, via tt set jira one instance-wide service account, from env

The keychain distinction is the whole point. On a laptop, "the stored credential" and "the person using the app" are the same thing. On a server they are not — the first person to set one up would leave it where every later request picked it up, and a stranger loading the page would be served as them. So nothing in hosted mode reads the keychain: there is no ambient identity on a server, only the one attached to the request being served. That is also why keyring is an optional dependency — a container can never reach the code that uses it.

Required

TT_HOSTED=1                       # implied when the platform sets PORT
TT_MANAGER_USER=someone           # replaces the published manager1/m@n@ger
TT_MANAGER_PASS=<long password>    # both required, or it refuses to boot

It refuses to start while manager1 / m@n@ger is still accepted. That credential ships in a public package and every privileged route accepts it, so on a public URL anyone who found the hostname could take the manager view and delete requests. Setting the two variables above replaces the default rather than adding to it.

Worth setting

TT_SECRET=<long random string>    # or each worker invents its own launch token
TT_ORIGINS=https://your.host      # explicit; same-origin is matched anyway
TT_SESSION_HOURS=12               # session lifetime, default 12
TT_JIRA_SITE=you.atlassian.net    # Jira, if wanted — see below
TT_JIRA_EMAIL=svc@you.com
TT_JIRA_TOKEN=<api token>

Running it

docker build -t teamtrack . && docker run -e PORT=7717 -p 7717:7717 \
  -e TT_MANAGER_USER=you -e TT_MANAGER_PASS=... -e TT_SECRET=... teamtrack

or on anything buildpack-shaped, via the Procfile:

uvicorn teamtrack.cli:app --host 0.0.0.0 --port $PORT --workers 1 \
  --proxy-headers --forwarded-allow-ips='*'

--proxy-headers matters: the same-origin check compares the browser's Origin against the Host/X-Forwarded-Host the request arrived on, which is how the app is reachable without being told its own hostname.

One worker

Sessions live in memory. Two workers keep two stores, so a session made on one is unknown to the other and users bounce between signed in and signed out. In-memory is a deliberate trade: the alternative is a file full of live GitHub tokens, which would then need its own encryption and key handling to be worth having. A restart signs everyone out. Move sessions to a shared store before raising the worker count.

Reached in laptop mode

If the process is running without TT_HOSTED and a request arrives at a non-loopback hostname, every route answers 503 with the variables to set. That combination is almost always a misconfigured deploy, and the failure it would otherwise produce — one shared GitHub identity for everybody — is silent and not recoverable after the fact.

Jira is shared, not per-user

Hosted, Jira reads one service account from TT_JIRA_*, and status() reports shared: true. An Atlassian API token belongs to a person, and there is nowhere to put fifty of them that is not a secrets store this app does not have — so anything created in Jira is attributed to that one account whoever clicked the button. The in-app link form is refused rather than quietly making one person's Jira act for everyone. (A container also has no keychain backend, so the write would fail anyway.)

Still true after all this

Hosting fixes identity. It does not fix either of these:

  • Rate limits. The board polls every 30s and costs 2N+1 GitHub calls per poll — one listing, N request reads, N status listings. GitHub allows 5,000/hour per token, so around 20 open requests saturates one user's budget from a single open tab. Each user now has their own budget, which helps, but the fix is a read cache.
  • Reporting. Cycle times across hundreds of requests would mean reading every event file of every request on each dashboard load. The event log already holds the raw material — every status change records who and when — but aggregating it wants SQL, not the Contents API.

Teams

A team is a folder in the storage repo. Managers create one from the Teams view:

  1. Name it — becomes teams/<slug>/, with its own board, its own components map
  2. Set a manager username and password for that team — optional
  3. Invite members by GitHub username, one per line

Which folder a write lands in

The folder is decided server-side from who you are, never from what the browser asks for:

Situation Folder used
Signed in to a team with its login that team, always — a request naming a different one is refused
Named a team you are a member of that team
Named a team you are not in 403
Named a team that does not exist 404 — folders no longer spring into existence
Named no team the shared root, data/…
You have admin/maintain on the repo any team in it — you own the repo

Membership means: listed in the team's members, or the team's creator, or the team's own login name.

The limit of a folder

Within the app, the table above holds. It cannot hold outside the app.

GitHub has no per-folder permission — access is granted per repo. So anyone with push access to the repo can write any team's folder by calling the GitHub API directly, bypassing this process entirely. A team folder is an organisational boundary that this app enforces, not one that GitHub enforces. Point TT_REPO at a separate repo for a team that needs a line holding no matter how it is approached.

The team password

Stored in teams/<slug>/team.json as a PBKDF2-SHA256 digest (200k iterations, random per-team salt) — never the password itself, so it cannot be read back out. Set it once and share it with the team yourself.

Like the global credential, it selects the manager view. Every button it reveals still re-checks role_for() against GitHub before acting.

Two hats, and who decides what

Two independent things gate every write:

Which hat you wear comes from the sign-in you used — nothing else:

Sign-in Hat Can
Manager roster (manager1) manager read the board, comment, acknowledge / in progress / resolve / reject. Cannot file.
Project login (project name + password) member file requests, comment, read the board. Cannot move a ticket.

What is possible at all comes from GitHub, checked on every write: no push access, no writes, whatever hat you are wearing. A read-only collaborator cannot file, move, or comment.

The hat is deliberately not derived from GitHub permissions. A manager owns their project repo, so GitHub always calls them an admin there — deriving the hat from that would leave them unable to sign in as a teammate on their own project at all. Sign in as a manager to close tickets; sign in to the project to file one.

One consequence, stated plainly: someone with push access who knows a manager password can move tickets, where the old rule demanded GitHub admin. That is not a real downgrade — push access already lets them write the event file directly through the API, bypassing this app. The hat chooses which operations are offered; it was never a security boundary and does not pretend to be.

The ticket thread

Click a request id on the board to expand it: its fields, then the full activity thread replayed from the event files, then a comment box. Managers get Acknowledge / In progress / Mark resolved / Reject underneath, and whatever is in the comment box rides along as the note on that status change.

Comments and status changes are both new files, so two people acting on the same ticket at the same moment cannot collide. A half-written comment survives the 30s board refresh.

The manager password is break-glass, and it used to be less than that

teamtrack/config.py holds a password roster seeded with manager1 / m@n@ger; TT_MANAGER_USER/TT_MANAGER_PASS replace it. Because the package is public, the default is public.

It was once genuinely only a view selector: every manager-only route re-asked GitHub whether the caller had push on the repo, so the password got you the manager screen and a 403 from every button on it. That second check went with GitHub. identity.effective_role() is the authorisation now, and this password is one of the three things that satisfies it — alongside TT_ADMINS and the manager roster.

So hosting.check_or_die() refuses to serve hosted with the default in place, and the roster is the route to use: per-person, revocable, and attributable, which a shared password is none of.


Linting

All local, all deterministic — regexes and set operations, no AI, no network call. Same draft in, same warnings out.

  • Completeness — required fields per request type, shown as filled/required
  • Vagueness — flags "not working", "broken", "asap", "sometimes", … and names what to supply instead
  • Missing evidence — no error output or repro steps on a bug, no number in affected, no first-seen time
  • Extraction — pulls the exception type and file:line out of a pasted traceback, linkifies bare URLs, fences code-shaped text, links REQ-… ids it finds
  • Duplicates — character-trigram Jaccard similarity against every open request, warning above 0.55
  • Severity — suggests, never overwrites

Warnings never block a submit. The author is the one who knows whether a box genuinely does not apply.


Blast radius

components.json is a hand-maintained dependency map, editable per project:

{
  "airbyte-sync": ["s3-iceberg"],
  "s3-iceberg":   ["clickhouse", "dbt-models"],
  "clickhouse":   ["client-ui"],
  "dbt-models":   ["client-ui"],
  "client-ui":    []
}

Pick a component and the UI breadth-first traverses the map, drawing the reported component and everything downstream of it, with the affected count underneath. Pure graph traversal — nothing is inferred.


CLI

tt                              # start the UI
tt --port 8765 --no-browser     # fixed port, no browser
tt whoami                       # print the signed-in GitHub login
tt signout                      # delete the token from the OS keychain
tt compact <owner> <repo> 2026-07                  # archive the repo root
tt compact <owner> <repo> 2026-07 --team data-platform   # archive one team folder

compact is the only place Parquet appears. It is a cold archive, never the write path — a whole-file rewrite is exactly the shared-mutable-file failure this design exists to avoid.


Keyboard

Key Does
cmd/ctrl + enter submit the request
esc close the shortcut sheet, or any open inline form
/ search the board
g then b go to the board
? shortcut sheet

Development

pip install -e .
export TT_CLIENT_ID=Ov23li...
tt

The UI is one file — teamtrack/static/index.html, vanilla HTML/CSS/JS. No framework, no bundler, no CDN, no webfonts; it works offline.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

teamtrack_tt-0.1.164.tar.gz (3.8 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

teamtrack_tt-0.1.164-py3-none-any.whl (3.7 MB view details)

Uploaded Python 3

File details

Details for the file teamtrack_tt-0.1.164.tar.gz.

File metadata

  • Download URL: teamtrack_tt-0.1.164.tar.gz
  • Upload date:
  • Size: 3.8 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for teamtrack_tt-0.1.164.tar.gz
Algorithm Hash digest
SHA256 6ba6d51c42685e6f529fdf816d19d51243c7655c505cdc01aa415082bd95ce6f
MD5 844f5fc04069433544a28d22269b0b0b
BLAKE2b-256 dcd8efe6d5d6276c083f10259a4053ad3fbba7877d8b75eccb098aa274eba5bd

See more details on using hashes here.

Provenance

The following attestation bundles were made for teamtrack_tt-0.1.164.tar.gz:

Publisher: deploy.yml on yogasathyandrun/team

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file teamtrack_tt-0.1.164-py3-none-any.whl.

File metadata

  • Download URL: teamtrack_tt-0.1.164-py3-none-any.whl
  • Upload date:
  • Size: 3.7 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for teamtrack_tt-0.1.164-py3-none-any.whl
Algorithm Hash digest
SHA256 e1535d87eb8b3000a933360d64d5666d5f1106b5a30bb904416e35e5e0944294
MD5 d4f5aa331d1ce9d01422cf210cb0e557
BLAKE2b-256 c700fb5629845f51d88b1aa6aa4e61c775c5b30caa0ddb861bc15d6ae9fbdb6c

See more details on using hashes here.

Provenance

The following attestation bundles were made for teamtrack_tt-0.1.164-py3-none-any.whl:

Publisher: deploy.yml on yogasathyandrun/team

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.1.1712

2 files

0.1.177

2 files

0.1.175

2 files

0.1.174

2 files

0.1.173

2 files

0.1.172

2 files

0.1.171

2 files

0.1.170

2 files

0.1.169

2 files

0.1.168

2 files

0.1.167

2 files

0.1.166

2 files

0.1.165

2 files

This release

0.1.164 This release

2 files

0.1.163

2 files

0.1.161

2 files

0.1.160

2 files

0.1.159

2 files

0.1.158

2 files

0.1.156

2 files

0.1.155

2 files

0.1.154

2 files

0.1.153

2 files

0.1.151

2 files

0.1.150

2 files

0.1.149

2 files

0.1.147

2 files

0.1.146

2 files

0.1.144

2 files

0.1.143

2 files

0.1.142

2 files

0.1.141

2 files

0.1.140

2 files

0.1.139

2 files

0.1.136

2 files

0.1.135

2 files

0.1.131

2 files

0.1.130

2 files

0.1.127

2 files

0.1.125

2 files

0.1.123

2 files

0.1.120

2 files

0.1.107

2 files

0.1.106

2 files

0.1.104

2 files

0.1.103

2 files

0.1.102

2 files

0.1.101

2 files

0.1.100

2 files

0.1.99

2 files

0.1.98

2 files

0.1.97

2 files

0.1.96

2 files

0.1.95

2 files

0.1.93

2 files

0.1.90

2 files

0.1.89

2 files

0.1.88

2 files

0.1.86

2 files

0.1.83

2 files

0.1.81

2 files

0.1.79

2 files

0.1.77

2 files

0.1.75

2 files

0.1.73

2 files

0.1.72

2 files

0.1.69

2 files

0.1.67

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page