Metadata-Version: 2.4
Name: teamtrack-tt
Version: 0.1.73
Summary: Web UI for filing and tracking team requests, stored as encrypted Parquet
License: MIT
Project-URL: Homepage, https://github.com/yogasathyandrun/team
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: server
Requires-Dist: fastapi>=0.110; extra == "server"
Requires-Dist: uvicorn>=0.27; extra == "server"
Requires-Dist: requests>=2.31; extra == "server"
Requires-Dist: pyarrow>=15.0; extra == "server"
Requires-Dist: cryptography>=42.0; extra == "server"
Requires-Dist: databricks-sdk>=0.29; extra == "server"
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Requires-Dist: httpx>=0.27; extra == "test"
Provides-Extra: laptop
Requires-Dist: keyring>=24.0; extra == "laptop"

# 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**):

```sh
export TT_CLIENT_ID=Ov23li...
```

### Publishing

```sh
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`](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

```sh
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

```sh
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

```sh
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`:

```sh
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:

```json
{
  "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

```sh
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

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

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