Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- Every command except `tfscli auth login`, when no API version is configured, now first asks the server with an `OPTIONS` request which version its resource supports and sends that version, so `wit work-items get-batch` and `wit wiql query-by-wiql` no longer fail with "No api-version was supplied" on servers that require one.
- A request sent without an API version because the server did not answer that question is now noted under `--verbose`, and if the server refuses it for the missing version, the `config` error names `apiVersion`, `TFSCLI_API_VERSION`, and `--api-version`.

## [0.1.0] - 2026-10-06

### Changed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ These come from `project-brief.md` and override casual feature requests. If a ch
- **Stateless.** No daemon, no background process, no on-disk cache. Every call hits the server.
- **Stable error contract.** Error categories (`auth`, `not_found`, `forbidden`, `server`, `config`, `network`) are a contract: existing categories are never removed or renamed. New ones may be added. Format: `Error [category]: message (HTTP status)` to stderr, non-zero exit.
- **Config precedence:** CLI flag > env var (`TFSCLI_PROJECT`, `TFSCLI_API_VERSION`) > config file (optional) > built-in default. The credential is outside this chain (see above).
- **Target is on-prem TFS / Azure DevOps Server; no `api-version` by default.** Requests carry `api-version` only when one is configured, and `auth login` never sends one. Cloud Azure DevOps Services may work incidentally but is not tested or supported.
- **Target is on-prem TFS / Azure DevOps Server; the API version is negotiated.** Without a configured version, each request is preceded by `OPTIONS {url}/{collection}/_apis` and carries the version the server lists for its resource; when the negotiation fails, the request goes out without a version, as before. A configured version is sent as is. `auth login` never sends one. Cloud Azure DevOps Services may work incidentally but is not tested or supported.
- **Do not narrow server compatibility.** A change must not stop tfscli from working with a TFS / Azure DevOps Server version it worked with before, unless that is an explicitly discussed and recorded decision (see "Server compatibility is preserved" in `project-brief.md`).
- **Works out of the box; errors lead the way.** Assume the user has read nothing and configured nothing. Do not add a required setting where a working default exists, and do not guess where the answer is ambiguous — ask the user instead. Every error a first-time user can hit names the concrete next step, and says when only a person can take it (see "Works out of the box; errors lead the way" in `project-brief.md`).
- **Minimal dependencies.** Total external deps are intentionally three: `cobra`, `html-to-markdown/v2`, and `golang.org/x/term` (hidden PAT input for `auth login`). HTTP via `net/http`, JSON via `encoding/json`, config via `encoding/json`. Don't pull in `viper`, `resty`, retry libraries, or YAML/TOML parsers without a strong reason and explicit discussion.
Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Every setting apart from the credential:
| Setting | Config key | Environment variable | Flag | Default | Required |
|---|---|---|---|---|---|
| Team project | `project` | `TFSCLI_PROJECT` | `-p`, `--project` | — | per command |
| REST API version | `apiVersion` | `TFSCLI_API_VERSION` | `--api-version` | none | no |
| REST API version | `apiVersion` | `TFSCLI_API_VERSION` | `--api-version` | negotiated with the server | no |
| Request logging | — | `TFSCLI_VERBOSE=1` | `--verbose` | off | no |
| CA bundle | `caBundle` | — | — | none | no |
| Skip certificate verification | `insecureSkipVerify` | — | — | `false` | no |
Expand All @@ -183,9 +183,11 @@ The config file is optional: every setting it holds, except the TLS ones, can be

### REST API version

By default no `api-version` is sent, and the server answers at the version it chooses. The commands that send a GET request, `wit work-items get` and `wit work-items list`, need no setting to work with any server release.
Without a configured version, tfscli asks the server for one. Each command first sends `OPTIONS` to `{url}/{collection}/_apis`, which lists every resource of the server with its versions, and the request then carries the released version of its resource. That is the version the server would choose for a request without one, so no setting is needed on any server release. The extra request shows under `--verbose`.

`wit work-items get-batch` and `wit wiql query-by-wiql` send a POST request, which a server may refuse without a version: it then reports a `config` error saying that no `api-version` was supplied, and a version has to be set with any of the three settings above. Otherwise set a version only to pin the shape of the response. `auth login` does not use this setting.
If the server does not answer `OPTIONS` with that list, the request is sent without a version, and `--verbose` says why. A server may refuse a POST request without a version — `wit work-items get-batch` and `wit wiql query-by-wiql` send one — and the `config` error then names the settings above. A token the server does not accept, or a server that cannot be reached, is reported at once.

A configured version is sent unchanged, without `OPTIONS`. Set one only for a server that refuses requests without a version, or to pin the shape of the response. `auth login` does not use this setting.

The version must not exceed the highest one the server supports:

Expand Down Expand Up @@ -317,11 +319,18 @@ An empty result reads `Work items: none` or `Relations: none`. The time of the r

### Request logging

`--verbose` writes one line per request to stderr — method, URL, status, duration — leaving stdout clean for the markdown. Headers and bodies are never logged, so the PAT cannot leak through it. A failed round trip is logged with status `0`.
`--verbose` writes one line per request to stderr — method, URL, status, duration — leaving stdout clean for the markdown. Headers and bodies are never logged, so the PAT cannot leak through it. A failed round trip is logged with status `0`. The `OPTIONS` request that negotiates the API version is logged like any other:

```
$ tfscli wit work-items get -p MyProject 12345 --verbose
GET https://tfs.company.com:8080/tfs/DefaultCollection/MyProject/_apis/wit/workitems/12345 200 86.4512ms
OPTIONS https://tfs.company.com:8080/tfs/DefaultCollection/_apis 200 41.2087ms
GET https://tfs.company.com:8080/tfs/DefaultCollection/MyProject/_apis/wit/workitems/12345?api-version=7.1 200 86.4512ms
```

Lines starting with `[warn]` report a condition worth knowing, such as disabled certificate verification; lines starting with `[info]` report what tfscli did on its own, such as sending a request without a version:

```
[info] api-version not negotiated (OPTIONS returned HTTP 405); sending the request without it
```

## Errors
Expand All @@ -340,7 +349,7 @@ The `(HTTP status)` part is omitted for errors that did not come from an HTTP re
| `forbidden` | Authenticated, but access was denied (HTTP 403). |
| `not_found` | The work item, project, or team does not exist (HTTP 404). During `auth login`, the server URL or the collection leads nowhere. |
| `server` | TFS failed or returned an unparseable response (HTTP 5xx). |
| `config` | Missing or invalid configuration, a bad argument, or a request TFS rejected. When TFS refuses a configured API version, the message names the setting it came from. |
| `config` | Missing or invalid configuration, a bad argument, or a request TFS rejected. When TFS refuses a configured API version, the message names the setting it came from; when it refuses a request for carrying no version, after the version could not be negotiated, the message names the settings that supply one. |
| `network` | The server could not be reached, or the request timed out or was canceled. |

Examples:
Expand Down
22 changes: 0 additions & 22 deletions docs/2026-10-04-tasks-first-run-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,25 +109,3 @@ optional for `wit work-items get`, taking server compatibility into account.
the Context section, and implementation tasks for the decision are added to
this file.
**Status:** Pending

### TASK-08 `missing-api-version`
**Description:** A server may refuse a POST request that carries no
`api-version` — `wit work-items get-batch` and `wit wiql query-by-wiql`
send one only when it is configured — with "No api-version was supplied for
the \"POST\" request". `classify` in `internal/apiclient/apiclient.go` adds
a next step to a version error only when a version is configured, so this
refusal reaches the user as the server's message alone, without the settings
that supply a version or how to choose its value. Add the next step for this
case: name `apiVersion` in the config file with the path of the file,
`TFSCLI_API_VERSION`, and `--api-version`, and say that the value must not
exceed the highest version the server supports, as listed in the
"Configuration" section of `README.md`. How the refusal is recognised is
unverified: the `typeKey` of this error is not in `versionErrorTypeKeys`
and has to be established from documentation or a sample response, falling
back to the message text only if no `typeKey` is available.
**Definition of done:** A refusal of a request that carries no `api-version`
produces a message naming the three settings, the config file path, and how
to choose the value; a refusal of a configured version keeps its current
message. Tests cover both. `README.md` and the representative messages in
`skills/tfscli/SKILL.md` match. `make check` passes.
**Status:** Pending
20 changes: 12 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ This section describes the conceptual module structure, their responsibilities,
▼
┌──────────────────┐ ┌─────────────────────┐
│ apiclient │◄─────►│ TFS REST API │
│ net/http + PAT │ TLS │ (no default ver.) │
│ + TLS + logger │ └─────────────────────┘
│ net/http + PAT │ TLS │ (version: config, │
│ + TLS + logger │ │ else OPTIONS) │
│ │ └─────────────────────┘
└────────┬─────────┘
│ raw JSON / *tfserr.Error
▼
Expand All @@ -174,26 +175,29 @@ This section describes the conceptual module structure, their responsibilities,

- **cli** — cobra commands, nested as area, resource, and action after the REST API reference (`wit` → `work-items` → `get`); persistent flags (`--verbose`, config overrides); orchestrates the chain config → apiclient → domain → printer → exit. Owns the markdown printer functions (one per resource and shape, such as a single work item and several) plus small per-kind scalar formatters for identity and datetime values used by those printers. No `Renderer` interface in v1 — printers are plain functions. When `--json` is added later, the interface and a second renderer can be introduced here without touching the domain layer.
- **config** — resolves the credential (`URL`, `Collection`, and `PAT`; a PAT is issued for one collection) from `TFSCLI_AUTH` or `$XDG_DATA_HOME/tfscli/auth.json`, never from a mix of sources; loads the optional `$XDG_CONFIG_HOME/tfscli/config.json`, applies environment overrides (`TFSCLI_*`), applies cobra flag overrides; validates that all required fields are set for the command being run. Writes `auth.json` for `auth login` (mode `0600`, directory `0700`). Holds `URL`, `Collection`, `PAT`, `Project`, `APIVersion`, `InsecureSkipVerify`, `CABundle`. Never logs PAT.
- **apiclient** — wraps `net/http`. Builds URLs from `Config.URL + Collection + path`, adding `api-version` only when `APIVersion` is set; without it the server answers at the version it chooses. Sets `Authorization: Basic base64(":<PAT>")` on every request. Configures TLS using `CABundle` (appended to system root pool) and `InsecureSkipVerify`. Hooks the logger via a `RoundTripper` — that transport is the single call site of `LogRequest`, so every outgoing request is logged exactly once and no other module logs HTTP traffic. Classifies HTTP outcomes into the stable error categories defined in the brief (`auth`, `not_found`, `forbidden`, `server`, `config`, `network`).
- **apiclient** — wraps `net/http`. Builds URLs from `Config.URL + Collection + path` and adds `api-version`. A configured `APIVersion` is sent as it is. Otherwise the version is negotiated per request, as the official SDKs do: `OPTIONS {URL}/{Collection}/_apis` lists every resource location with its versions, the caller names its location by id, and the request carries the location's released version, or `{maxVersion}-preview.{resourceVersion}` when it has none — the version the server chooses for a request without one. The response is not cached. A 401 or a transport failure on `OPTIONS` fails the request; any other failure sends it without a version and notes why through `Logger.Info`, and a refusal of that request for the missing version gets the settings to use in its message. A call without a location, the login check, neither negotiates nor sends a version. Sets `Authorization: Basic base64(":<PAT>")` on every request. Configures TLS using `CABundle` (appended to system root pool) and `InsecureSkipVerify`. Hooks the logger via a `RoundTripper` — that transport is the single call site of `LogRequest`, so every outgoing request is logged exactly once and no other module logs HTTP traffic. Classifies HTTP outcomes into the stable error categories defined in the brief (`auth`, `not_found`, `forbidden`, `server`, `config`, `network`).
- **workitem** — domain logic for Work Items: Get Work Item, Work Items - List, and Get Work Items Batch. Owns the `WorkItem`/`Field` types, the `BatchRequest`/`Batch` types for reading several work items, and the allowlist of fields known to contain HTML (`System.Description`, `Microsoft.VSTS.TCM.ReproSteps`, `Microsoft.VSTS.TCM.SystemInfo`, `Microsoft.VSTS.Common.AcceptanceCriteria`, …). After unmarshalling, tags each field with its `FieldKind`. For several work items, keeps the server's order and reports the requested ids the server did not return. Returns raw values — does not perform markdown conversion.
- **wiql** — domain logic for Wiql: Query By Wiql. Owns the `Request` and `Result` types. Builds the path at collection level, under a project, or under a project and a team; passes the query text through unchanged and sends `$top` and `timePrecision` only when they are given. Reads the query type, `asOf`, the reference names of the columns, and either the work item ids of a flat query or the relations of a link query, keeping the server's order. Does not read the work items themselves: that is a separate call through `workitem`, made by the caller.
- **htmlmd** — thin wrapper over `github.com/JohannesKaufmann/html-to-markdown/v2`. One method: `Convert(html string) (string, error)`. v1 uses library defaults only; the wrapper is the extension point where TFS-specific rules will be registered once real HTML samples have been collected (see the `html-quirks` entry in `docs/backlog.md`).
- **tfserr** — typed error `Error{Category, Message, HTTPStatus, Cause}` with the stable category set from the brief. Provides `Print(err, w)` writing `Error [category]: message (HTTP status)` and `ExitCode(err)` mapping to a non-zero process exit code (v1: 1 for every category; the API leaves room for per-category exit codes later without breaking the contract).
- **log** — `Logger` interface with two implementations: `noop` (default) and `stderr` (selected by `--verbose` / `TFSCLI_VERBOSE=1`). Methods: `LogRequest(method, url, status, dur)` and `Warn(msg)`. The logger interface accepts only these four request fields — PAT and the `Authorization` header are never passed in, so they cannot leak through the logger by construction.
- **log** — `Logger` interface with two implementations: `noop` (default) and `stderr` (selected by `--verbose` / `TFSCLI_VERBOSE=1`). Methods: `LogRequest(method, url, status, dur)`, `Warn(msg)` for a condition worth reporting, and `Info(msg)` for what tfscli did on its own, such as sending a request without a version after a failed negotiation. The logger interface accepts only these four request fields — PAT and the `Authorization` header are never passed in, so they cannot leak through the logger by construction.

### Key dependency interfaces

These are the boundaries between modules. Final signatures may evolve during Step 4.

```go
// location is the id under which the server lists the resource of the
// request in its response to OPTIONS on _apis; empty for no negotiation.
type APIClient interface {
Get(ctx context.Context, path string, query url.Values) ([]byte, error)
Post(ctx context.Context, path string, query url.Values, body any) ([]byte, error)
Get(ctx context.Context, location, path string, query url.Values) ([]byte, error)
Post(ctx context.Context, location, path string, query url.Values, body any) ([]byte, error)
}

type Logger interface {
LogRequest(method, url string, status int, dur time.Duration)
Warn(msg string)
Info(msg string)
}

type Converter interface {
Expand Down Expand Up @@ -271,8 +275,8 @@ type Result struct {
2. `config.Load` takes `URL`, `Collection`, and `PAT` from `TFSCLI_AUTH` or `auth.json`, which must carry all three; reads the config file if present, overlays env vars, overlays bound flags. The command then checks that `Project` is supplied either by `-p`, env, or config default.
3. cli builds a `Logger` (noop or stderr depending on `--verbose`) and an `apiclient.APIClient`. The TLS config is derived from `CABundle` and `InsecureSkipVerify`. If verification is disabled, the logger emits one `[warn] TLS verification disabled` line.
4. cli calls `workitem.Get(ctx, client, project, id, fields)`.
5. `workitem.Get` constructs the path `{Project}/_apis/wit/workitems/{id}` with the `fields` query parameter and calls `client.Get`, which prefixes the collection and adds `api-version={APIVersion}` only when a version is configured.
6. `apiclient.Get` builds the request, sets the `Authorization` header, and executes it through its `RoundTripper`, which calls `LogRequest` once with method, URL, status, and duration. On non-2xx the response is classified into a `*tfserr.Error`; otherwise the response body is returned.
5. `workitem.Get` constructs the path `{Project}/_apis/wit/workitems/{id}` with the `fields` query parameter and calls `client.Get` with the location id of Work Items, `72c7ddf8-2cdc-4f60-90cd-ab71c14a399b`.
6. Without a configured version, `apiclient.Get` first sends `OPTIONS {Collection}/_apis` and takes the version listed for that location; with one, it uses the configured version. It then builds the request with `api-version`, sets the `Authorization` header, and executes it through its `RoundTripper`, which calls `LogRequest` once with method, URL, status, and duration. On non-2xx the response is classified into a `*tfserr.Error`; otherwise the response body is returned.
7. `workitem.Get` unmarshals the JSON, walks `fields`, sets each `Field.Kind` using the HTML allowlist plus known identity/datetime field types, and returns `*WorkItem`.
8. The cli printer iterates `wi.Fields`. HTML fields go through `htmlmd.Convert`. Identity and datetime fields are formatted by small dedicated helpers. Plain fields print as is. Output is written to `stdout`.
9. On error at any step: `tfserr.Print(err, os.Stderr)`; `os.Exit(tfserr.ExitCode(err))`.
Expand Down
Loading
Loading