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
88 changes: 88 additions & 0 deletions .claude/scripts/tc_branch_builds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""List queued and running TeamCity builds for a given branch."""

import argparse
import json
import subprocess
import sys


def tc_api_all(endpoint: str) -> list[dict]:
"""Fetch all pages from a REST endpoint using --paginate --slurp."""
result = subprocess.run(
["teamcity", "api", endpoint, "--paginate", "--slurp"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Error: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
data = json.loads(result.stdout)
# --slurp returns a flat list of objects
return data if isinstance(data, list) else []


def list_queued(branch: str) -> list[dict]:
builds = tc_api_all("/app/rest/buildQueue?fields=build(id,state,branchName,buildTypeId,webUrl,buildType(name,projectName))")
return [b for b in builds if (b.get("branchName") or "") == branch]


def list_running(branch: str) -> list[dict]:
builds = tc_api_all("/app/rest/builds?locator=running:true&fields=build(id,state,branchName,buildTypeId,webUrl,percentageComplete,buildType(name,projectName))")
return [b for b in builds if (b.get("branchName") or "") == branch]


def print_builds(builds: list[dict], title: str) -> None:
print(f"\n{'='*60}")
print(f" {title} ({len(builds)})")
print(f"{'='*60}")
if not builds:
print(" (none)")
else:
for b in builds:
bt = b.get("buildType", {})
print(f" ID : {b['id']}")
print(f" Job : {bt.get('name', b.get('buildTypeId', '?'))}")
print(f" Project : {bt.get('projectName', '?')}")
print(f" Branch : {b.get('branchName', '?')}")
if b.get("percentageComplete") is not None:
print(f" Progress: {b['percentageComplete']}%")
print(f" URL : {b.get('webUrl', '?')}")
print()


def main() -> None:
parser = argparse.ArgumentParser(
description="List queued and running TeamCity builds for a branch."
)
parser.add_argument("branch", help="Exact branch name to filter by")
state_group = parser.add_mutually_exclusive_group()
state_group.add_argument("--queued-only", action="store_true", help="Only show queued builds")
state_group.add_argument("--running-only", action="store_true", help="Only show running builds")
fmt_group = parser.add_mutually_exclusive_group()
fmt_group.add_argument("--json", action="store_true", dest="as_json", help="Output raw JSON")
fmt_group.add_argument("--ids", action="store_true", help="Output build IDs only, one per line")
args = parser.parse_args()

show_queued = not args.running_only
show_running = not args.queued_only

queued = list_queued(args.branch) if show_queued else []
running = list_running(args.branch) if show_running else []

if args.ids:
for b in queued + running:
print(b["id"])
elif args.as_json:
print(json.dumps({"queued": queued, "running": running}, indent=2))
else:
print(f"\nBranch filter: {args.branch!r}")
if show_queued:
print_builds(queued, "Queued Builds")
if show_running:
print_builds(running, "Running Builds")
print(f"\nTotal: {len(queued) + len(running)} build(s)")


if __name__ == "__main__":
main()
78 changes: 78 additions & 0 deletions .claude/skills/tc-branch-cleanup/skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: tc-branch-cleanup
description: "Cancel all queued and running TeamCity builds for a branch. Intended for post-merge cleanup. Invoke as `/tc-branch-cleanup <branch>` with optional --comment."
---

# TC Branch Cleanup

## Intent

Use this skill when the user wants to cancel all queued and running TeamCity builds for a branch — typically after the branch has been merged and those builds are no longer relevant.

**This is a destructive, irreversible action.** Always show the user what will be canceled and require explicit confirmation before proceeding.

## Argument Parsing

- **branch** (required positional) — the exact branch name to cancel builds for
- **--comment** (optional) — cancellation message stored on each build in the TeamCity UI (default: `"Branch merged — canceling remaining builds"`)

If no branch name is provided, ask the user for one before proceeding.

## Execution Steps

### Step 1 — Discover builds

Run the discovery script to find all queued and running builds for the branch:

```bash
python3 .claude/scripts/tc_branch_builds.py <branch> --json
```

Parse the JSON output. It has the shape:
```json
{
"queued": [ { "id": "...", "buildType": { "name": "...", "projectName": "..." }, ... } ],
"running": [ { "id": "...", "buildType": { "name": "...", "projectName": "..." }, ... } ]
}
```

### Step 2 — Confirm with user

If there are no builds (both lists empty), report that and stop — nothing to do.

Otherwise, display a summary table like:

```
Found N build(s) to cancel for branch '<branch>':

STATE ID PROJECT / JOB
queued 12345 MyProject / Build & Test
running 12346 MyProject / Deploy
...

This will cancel all of the above. Proceed? (yes/no)
```

**Do not proceed until the user explicitly confirms.** If they decline, stop.

### Step 3 — Cancel all builds

For each build ID (queued and running alike), run:

```bash
teamcity run cancel <id> --yes --comment "<comment>"
```

Run these sequentially, reporting success or failure for each one as you go.

### Step 4 — Summary

After all cancellations are attempted, report:
- How many succeeded
- Any that failed (with the error), so the user can investigate

## Error Handling

- If the discovery script exits non-zero, show the error and stop before asking for confirmation.
- If an individual `teamcity run cancel` fails, log the failure and continue with the remaining builds — do not abort the whole operation.
- If the `teamcity` CLI is not on PATH, tell the user to add it to their PATH or install it by following the instructions at https://www.jetbrains.com/help/teamcity/teamcity-cli.html#installing
36 changes: 31 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ LabKey Server is a large Java web application platform for biomedical research d
./gradlew -PmoduleSet=distributions :distributions:base:dist
```

## Running Tests
## Tests

**Unit tests** are static `TestCase` inner classes within production source files. They are registered via the module's `getUnitTests()` method and run within the server JVM.

**Integration tests** require a running server and database. They are registered via `getIntegrationTests()`.

**Selenium UI tests** (in `server/testAutomation/`):
**Selenium UI tests** (in `server/testAutomation/` and the `test` directory of many modules):
```bash
./gradlew :server:testAutomation:initProperties # Generate test.properties
./gradlew :server:testAutomation:uiTests -Psuite=DRT # Run a test suite
Expand Down Expand Up @@ -108,8 +108,15 @@ All external library versions are centralized in `gradle.properties` (200+ versi
```java
private static final Logger LOG = LogHelper.getLogger(MyClass.class, "optional description");
```
- **Unit tests**: Create a static `TestCase` inner class extending `Assert` in the same file as production code. Use JUnit 4 annotations (`@Test`). Register new test classes in the owning module's `getUnitTests()`.
- **Selenium tests**: Subclass `BaseWebDriverTest`. Use a `@BeforeClass` for setup and override `doCleanup()` for cleanup. See `SecurityTest` as an example.
- **Unit tests**: Create a static `TestCase` inner class extending `Assert` in the same file as production code. Use JUnit 4 annotations (`@Test`). Register new test classes in the owning module's `getUnitTests()` (or `getIntegrationTests()` if the test requires the server to be running).
- **Selenium tests**
- Subclass `BaseWebDriverTest`. Use a `@BeforeClass` for setup and override `doCleanup()` for cleanup. See `SecurityTest` as an example.
- Templates for Selenium test classes and page objects are in '.idea/fileTemplates/'
- **Page/component objects over raw locators**: When a `DataRegionTable`, `CustomizeView`, or other component method returns a typed page or component object, use that object's API rather than falling back to `setFormElement`/`Locator` calls. For example, `DataRegionTable.clickInsertNewRow()` returns an insert-row page object whose fields should be set through its typed methods.
- **Remote API library over UI for setup**: When setting up a project for testing, use classes from `org.labkey.remoteapi` for setup rather than navigating through the UI. Create test-specific API wrappers for actions that are not yet exposed in the `labkey-api-java` library.
- **Use API helpers over raw Commands**: Helpers such as `org.labkey.test.params.assay.AssayDesign` and `org.labkey.test.params.experiment.SampleTypeDefinition` wrap multiple API calls into a single operation or add additional functionality.
- **Never navigate in 'finally' blocks or JUnit '@After'/'@AfterClass' methods**: It prevents the base class from collecting failure screenshots. These sorts of cleanup methods should exclusively use API calls.
- Take screenshots of errors collected by `DeferredErrorCollector` before taking any actions that modify the page state.
- **Formatting**: Follow IntelliJ IDEA project settings in `.idea/codeStyles/Project.xml`.

## Key Build Properties (`gradle.properties`)
Expand All @@ -127,7 +134,7 @@ When searching for Java method usages, always include `*.jsp` and `*.jspf` files
## Git Branch Naming

- `develop` — primary development branch (protected; no direct commits).
- `fb_<label>_<id>` — feature/bug-fix branch off `develop`. `label` is a short snake_case description (use underscores to separate words, not dashes); `id` is the issue or Scrumwise ID. Omit `_<id>` only when no ID exists (e.g., test fixes); coordinate the label to avoid collisions.
- `fb_<label>_<id>` — feature/bug-fix branch off `develop`. `label` is a short snake_case description (use underscores to separate words, not dashes); `id` is the GitHub issue ID. Omit `_<id>` only when no ID exists (e.g., test fixes); coordinate the label to avoid collisions.
- `XX.Y_fb_<label>_<id>` — feature/bug-fix branch targeting a specific release.
- `releaseXX.Y-SNAPSHOT` — beta release branch (protected); base release-targeted feature branches from it.
- `releaseXX.Y` — final release branch (protected); receives merges from the SNAPSHOT branch only. Patch releases are tagged `XX.Y.Z`.
Expand All @@ -153,3 +160,22 @@ Subject: short imperative (≈70 chars). Body: follow the formatting rule above
If the repo has a `pull_request_template.md` (typically under `.github/`), follow it. Otherwise, include sections for: **Rationale** (why the change is needed), **Related Pull Requests**, and **Changes** (notable items). Keep descriptions brief. Follow the formatting rule above — one physical line per paragraph and per bullet.

Before opening a PR, always draft the title and description and confirm them with the user. Do not run `gh pr create` until the user approves.

## Enlistment Structure
```
./ ← root repo (https://github.com/LabKey/server)
├── distributions/ ← optional; distribution configurations
├── remoteapi/ ← optional; Java API repos
│ ├── labkey-api-java/ ← LabKey Java Client API
│ └── labkey-api-jdbc/ ← LabKey JDBC Driver
├── server/
│ ├── modules/
│ │ ├── platform/ ← core platform modules
│ │ └── */ ← additional module repos cloned here
│ └── testAutomation/ ← core Selenium tests
└── clientAPIs/ ← optional; front-end packages cloned here or via env vars
├── labkey-api-js/
├── labkey-ui-components/ ← $LABKEY_UI_COMPONENTS_HOME
└── labkey-ui-premium/ ← $LABKEY_UI_PREMIUM_HOME
```
All repositories are under the `LabKey` GitHub organization (https://github.com/LabKey), with the root repo at `LabKey/server`.