
Ship
- 29 installs
- 6 repo stars
- Updated July 22, 2026
- julianobarbosa/claude-code-skills
End-to-end branch delivery: commit, push, open PR, ensure Board work item exists, link it, merge, clean up branch and worktree. Auto-detects Azure or GitHub.
About
End-to-end branch delivery - commit, push, PR, Board item, link, merge, cleanup.. Auto-detects Azure Repos or GitHub, handles auth, manages worktrees.
- intermediate skill
- core: release management
Ship by the numbers
- 29 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #154 of 248 Release Management skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/julianobarbosa/claude-code-skills --skill shipAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 22, 2026 |
| Repository | julianobarbosa/claude-code-skills ↗ |
What it does
End-to-end branch delivery: commit, push, open PR, ensure Board work item exists, link it, merge, clean up branch and worktree. Auto-detects Azure or GitHub.
Files
Ship
ship takes a finished branch the last mile: commit it cleanly, push it (working around Azure DevOps auth when needed), open a pull request, link it to its work item, and — once it's merged — tear down the branch and worktree. It auto-detects whether you're on Azure Repos or GitHub and follows the matching path, so the same command works at Hypera and in a github.com repo.
It is deliberately narrow. It does not merge locally, run pipelines, or manage backlogs — it hands a reviewable PR to the platform and cleans up after the merge.
When to use this vs. neighbors
- `ship` — the PR-based delivery flow: push → PR → (merge happens via the platform) → cleanup.
- `commit` — just stage + commit + push the current branch, no PR.
- `merge` — merge a branch into
mainlocally (fast-forward) and clean up. Use this when there's
no PR gate; use ship when changes must go through review/policy.
- `azure-devops` — the deep REST/MCP toolbox (WIQL, batch updates, pipelines, comment threads).
ship calls only the thin slice it needs; reach for azure-devops for anything richer.
The flow
Run bun scripts/ship-detect.ts first — it prints the platform, the Azure org/project/repo (if any), the branch, and an inferred work-item id. Everything below branches on that.
One-time setup: the scripts depend onazure-devops-node-apiand@octokit/rest. Run
bun installin the skill dir (~/.claude/skills/ship/) once before first use.
0. Preflight
- Confirm there's something to deliver:
git statusandgit log --oneline @{u}.. 2>/dev/null. - Note the platform from
ship-detect.ts. If it saysunknown(e.g. a custom SSH host alias),
set SHIP_PLATFORM=azure or SHIP_PLATFORM=github for the session.
1. Commit — as the author, never as the tool
Stage only files for this task and commit. Never add AI attribution — no Generated with Claude, no Co-Authored-By: Claude. Commits are authored by the human; tooling provenance does not belong in git history (this repo's commit-msg hook strips trailers as a backstop, but don't rely on it — don't write them in the first place). If pre-commit hooks fail on unrelated issues, --no-verify is acceptable; if they flag your change, fix it.
2. Branch posture
- On a feature branch → good, continue.
- On `main`/`master` → you can't open a PR from main into itself. Create a branch and move the
commit onto it before pushing:
git branch feature/<slug> && git reset --hard @{u} && git checkout feature/<slug>(Only do this when the commits aren't yet pushed to main. If unsure, stop and ask.)
- A blocked direct push to main (branch policy) is the signal to take the PR path — not to
force-push or bypass the policy.
3. Push
bun scripts/ship-push.ts # pushes current branch, sets upstreamOn Azure DevOps, if the normal push fails on auth, this automatically mints an az OAuth token and retries with a Bearer header — the fallback for when neither SSH nor an HTTPS credential helper is available. See references/azure-devops.md for the mechanics.
4. Open the PR (+ ensure/link work item, + transition state)
Invariant — never ask the user for the work item id. Resolving → verifying → (if missing)
creating + assigning + linking the work item is fully automatic. A PR must never be surfaced
as ready without a linked Board work item. If you're about to ask "what's the work-item id?",
stop — that question is the exact failure this skill exists to prevent. The id comes from
--work-item, the branch name, or a freshly created item; it never comes from a question.>
Code review does not own the work item. Because ship guarantees a linked item at PR-open,
the review step — human reviewers or thecode-review/bmad-code-reviewskills — must not
re-prompt for or block on a work item. That concern is already satisfied upstream.
Draft a real description — copy assets/pr-template.md to a temp file, fill Summary / Changes / Verification, and keep the AB#<id> line so the work item links.
bun scripts/ship-pr.ts --title "<title>" --body-file /tmp/pr-body.md \
--work-item <id> --transition Resolved- Platform is auto-detected; the script uses the azure-devops-node-api SDK (
createPullRequest)
or Octokit (pulls.create).
- Azure never opens a PR without a linked Board work item. The script resolves the id from
--work-item (or the branch name), then verifies it actually exists via the SDK (getWorkItem). The branch-name parse is only a heuristic — a branch like 1234-foo can carry a number that isn't a real item, which is why existence is checked, not assumed.
- If no real work item is found, one is created per task and linked to the PR, each **assigned
to** $SHIP_ADO_ASSIGNEE (default juliano.barbosa@hypera.com.br; override with --assignee). Pass --task "<title>" once per task (repeatable), or --tasks-file <file> (one title per line), to create one item each. With no --task, a single item is created from the PR --title. Type defaults to Task (--work-item-type "User Story"|Bug). Disable creation with --no-create-work-item to link only a pre-existing id.
- The script prints
work_item_created=<id>per created item andwork_items=<ids>for the set —
surface the new ids to the user (don't fabricate them; they come from the SDK response).
--transition(Azure only) moves each linked/created item after the PR opens. State names
are process-specific (Agile: Resolved; Scrum: Committed; Basic: Doing) — verify the valid next state first; see references/azure-devops.md. Omit --transition to leave the board untouched. (Created items start in the type's initial state, e.g. New/To Do.)
- The script prints
pr_url=…; surface it to the user.
4b. Tag the PR (optional, recommended)
Azure DevOps calls them tags; GitHub calls them labels — same idea: a small, visible signal that helps reviewers triage and helps the team organize PRs. Microsoft's guidance is that tags "communicate extra information to reviewers, such as that the PR is still a work in progress, or is a hotfix for an upcoming release" (Add tags to a pull request).
Apply tags at PR-open time with --tag (comma-separated and/or repeatable):
bun scripts/ship-pr.ts --title "<title>" --body-file /tmp/pr-body.md \
--work-item <id> --transition Resolved --tag "hotfix,do-not-merge"…or manage tags on an already-open PR with ship-tag.ts (the id is pr_id= on Azure / pr_number= on GitHub, both printed by ship-pr.ts):
bun scripts/ship-tag.ts <pr-id> --add "needs-review" # add
bun scripts/ship-tag.ts <pr-id> --remove "do-not-merge" # remove
bun scripts/ship-tag.ts <pr-id> --list # show current tagsTags are free-form on both platforms (Azure creates the tag definition on first use; GitHub auto-creates a missing label). For a recommended, consistent tag set — do-not-merge, work-in-progress, hotfix, plus type/area conventions — see the Recommended tags section of references/azure-devops.md / references/github.md.
5. After merge — cleanup
Do this only once the PR is actually merged (don't delete a branch with an open PR). Mirror the merge skill's cleanup, and ask before deleting:
- If the branch was developed in a worktree,
cdto the main repo dir first, then
git worktree remove <path> — you can't delete a branch from inside its own worktree.
git branch -d <branch>(lowercase-drefuses unmerged branches — that refusal is the safety
net; only escalate to -D if the user explicitly confirms).
git push origin --delete <branch>(ignore failure if it was local-only).
6. Snapshot the state (optional)
Independent of the PR flow — reach for this any time you want a return-to point: after a clean merge to main, before a risky migration, or just to mark "everything works here." It creates an annotated git tag on HEAD (the working tree is irrelevant — a tag names a commit).
bun scripts/ship-snapshot.ts \
-m "what changed / why you're saving here" \
-m "verification status"The script auto-fills what's easy to get wrong by hand:
- Name defaults to
v<YYYY.MM.DD-HHMM>(minute-granular so repeated snapshots in a day don't
collide). Pass --daily for a once-a-day v<YYYY.MM.DD>, or --name <tag> for an exact name.
- First message paragraph is auto-generated —
Snapshot <tag> — <branch> @ <shorthash>— so the
tag is self-describing even with no -m. Each -m you add becomes its own paragraph (passed via an arg array, so real newlines/punctuation survive — unlike a \n inside a single shell string).
- Collision is refused, not clobbered (exit 3) — a snapshot must never silently move a tag
someone relies on.
Tags are local until pushed (shared/visible — confirm first, like opening a PR). Add --push to push to origin; on Azure it falls back to the az OAuth Bearer header the same way ship-push.ts does. The script prints tag=, commit=, branch=, pushed=.
Bundled tools
| Script | Does |
|---|---|
bun scripts/ship-detect.ts [remote] | Print platform + Azure coordinates + branch + inferred work item |
bun scripts/ship-push.ts [-r remote] [-b branch] | Push + set upstream; Azure OAuth Bearer fallback on auth failure |
bun scripts/ship-pr.ts --title … [opts] | Open PR on the detected platform; ensure/create + link work item(s) (assigned to the configured user); optional Board transition; optional --tag |
| `bun scripts/ship-tag.ts <pr-id> [--add\ | --remove "t1,t2"] [--list]` |
bun scripts/ship-snapshot.ts [-m "para"]… [--daily] [--name <tag>] [--push] | Save current state as an annotated git tag (date-named, self-describing subject); optional push with Azure OAuth fallback |
Scripts are TypeScript run via bun; shared helpers live in scripts/ship-lib.ts. Run bun install in the skill dir once (installs azure-devops-node-api + @octokit/rest). Git/push stays a subprocess call (it's a git operation); only the ADO/GitHub REST surfaces use the SDKs. Platform detail lives in references/azure-devops.md and references/github.md — read the one matching the detected platform. The PR description starts from assets/pr-template.md.
Safety
- Ask before anything destructive or shared-visible — deleting branches/worktrees, and opening a
PR (it's visible to the team). Pushing a feature branch and creating a draft are low-risk; a ready PR and any branch deletion warrant a confirm.
- Never force-push to work around a rejected push. A rejection means diverged history or a
policy — investigate, don't overwrite.
- Tokens go in headers, are short-lived, and are never printed or put in URLs.
- No AI attribution anywhere — commit message, PR title, or PR body.
Gotchas
- Both PR-create paths (Octokit
pulls.create, ADOcreatePullRequest) require the branch to be
pushed first — keep step 3 before step 4.
ship-detect.tsreturningunknownis almost always a custom SSH host alias — setSHIP_PLATFORM.
(A dev.azure.com-<alias> SSH host is detected as azure but its org/project/repo won't parse — same limitation the shell version had; pass coordinates/--work-item explicitly if needed.)
- Scripts need deps: if you see
Cannot find module 'azure-devops-node-api', runbun installin
the skill dir.
- A linked work item does not change state on its own; transitioning is a separate step (4).
- If
AZURE_DEVOPS_EXT_PATis set but under-scoped (e.g. Code-only, missing Work Items / Pull
Request write), ship-pr.ts auto-detects the 401/403 and retries with the az OAuth bearer token — no need to unset the PAT manually. The fallback requires az login as an org member.
- Don't hand-roll the PR with raw
az repos pr create/gh pr createand then bolt the work item
on afterward — that path skips the auto-create+link invariant and forces a manual "what's the id?" round-trip with the user. Use ship-pr.ts so the work item is guaranteed at PR-open.
- Deleting a branch while its PR is still open abandons the PR — clean up only after merge (step 5).
- Three different "tags" — don't mix them up. (1) PR tags/labels (
--tag,ship-tag.ts) — a
triage signal on the PR. (2) Work items (step 4) — the traceability link. (3) Git tags (ship-snapshot.ts) — a commit checkpoint in history. ship-tag.ts takes a PR id; ship-snapshot.ts takes no positional arg and tags HEAD. Same word, three concepts.
- Re-adding a tag is safe — Azure
createPullRequestLabelreturns the existing definition rather
than erroring, and GitHub dedupes; parseTags also drops blank/duplicate names from the CSV.
- `ship-tag.ts` needs the PR id — Azure uses the numeric
pr_id, GitHub uses thepr_number
(both emitted by ship-pr.ts). On GitHub a tag that names a non-existent label is auto-created.
node_modules/
bun.lock
Summary
<one or two lines: what this changes and why>
Changes
- <change 1>
- <change 2>
Verification
- [ ] <how this was checked — tests, build, manual run>
--- <!-- Azure DevOps: AB#<id> renders as a live work-item link. --> <!-- GitHub: use Closes #<id> instead to auto-close the issue on merge. --> AB#<work-item-id>
{
"name": "ship-skill",
"private": true,
"type": "module",
"description": "Runtime deps for the ship skill's TypeScript scripts (run via bun).",
"dependencies": {
"@octokit/rest": "^21.1.1",
"azure-devops-node-api": "^14.1.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}
Ship — Azure DevOps path
Detail for the Azure Repos + Boards half of ship. Read this when the remote is dev.azure.com / *.visualstudio.com.
Authentication: the OAuth Bearer fallback
Azure DevOps git over HTTPS needs a credential. On a workstation where the SSH key isn't loaded and no HTTPS credential helper is configured, both push transports fail — but if az login is done, you can borrow its token.
scripts/ship-push.ts does this automatically: it tries a normal git push first, and only on failure mints a token and retries with a Bearer header. The mechanics, if you ever run it by hand:
TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
git -c http.extraHeader="Authorization: Bearer $TOKEN" push -u origin <branch>499b84ac-1321-427f-aa17-267ca6975798is the constant Azure DevOps API resource id — not a secret, the same for every org.- The token lives ~60 minutes. Re-mint per command if a long session crosses the boundary.
- The token goes in a header, never in the URL — URLs leak to logs, history, and referrers.
- The same token drives the SDK:
ship-pr.tspasses it toazdev.getBearerHandler(token)and builds aWebApiconnection (noAZURE_DEVOPS_EXT_PATenv var needed — that was the CLI path).
Coordinates
The SDK needs the org URL + project + repo. ship-pr.ts parses them from the remote URL (via adoParts in ship-lib.ts) so it works on any clone, with no reliance on az devops configure --defaults machine state. Run bun scripts/ship-detect.ts to see what it resolved.
Linking work items
Two complementary mechanisms — use both:
1. Hard link — az repos pr create --work-items <id> [<id> …] attaches work item(s) to the PR (space-separated for multiple). This is what drives traceability and, with branch policies, can auto-resolve on completion. 2. `AB#<id>` mention — putting AB#1234 in the PR description (the template does) renders as a live link in the Azure DevOps UI.
ship-pr.ts infers the id from the branch name (feature/AB1234-foo, 1234-foo) when --work-item is omitted. The inference is a heuristic — pass --work-item explicitly when the branch name doesn't carry it.
Ensure-or-create (a PR always carries a work item)
ship-pr.ts verifies the resolved id before trusting it (SDK wit.getWorkItem(id)), then creates items when none exist (wit.createWorkItem(...) with a JSON-patch document), and links them on PR create (git.createPullRequest({ ..., workItemRefs })). The equivalent CLI calls, for reference:
# Existence check — the branch parse is a heuristic, so confirm the id is real:
az boards work-item show --id <id> --organization <org_url> --query id -o tsv
# Create one item per task, assigned to a user, and capture the new id:
az boards work-item create --type "Task" --title "<task title>" \
--assigned-to "juliano.barbosa@hypera.com.br" \
--organization <org_url> --project <project> --query id -o tsvThe SDK builds the same JSON-patch document the CLI sends — /fields/System.Title and /fields/System.AssignedTo on create, /fields/System.State on transition.
--assigned-toaccepts a UPN/email/display name.ship-pr.tsdefaults it to$SHIP_ADO_ASSIGNEE(fallbackjuliano.barbosa@hypera.com.br); override per-run with--assignee.- One work item is created per `--task` (or per line of
--tasks-file); with no tasks given, a single item is created from the PR title. All created ids are passed toaz repos pr create --work-itemsin the same call, so creation and linking happen together. - Created items start in their type's initial state (
Newfor Agile/Scrum/CMMI,To Dofor Basic). Use--transitionto advance them once the PR is open; it now applies to every linked id. - Linking an item to an already-open PR (when you didn't create it at PR-open time) is a separate command:
az repos pr work-item add --id <pr_id> --work-items <id> --organization <org_url>. - Report the
work_item_created=<id>/work_items=<ids>lines the script emits — never invent a work-item id; it must come from realazoutput.
Transitioning Board state
State names depend on the project's process, so there's no universal "Resolved". Check before transitioning:
az boards work-item show --id <id> --query "fields.\"System.WorkItemType\"" -o tsv| Process | Typical states (New → Done) |
|---|---|
| Agile | New → Active → Resolved → Closed |
| Scrum | New → Approved → Committed → Done |
| Basic | To Do → Doing → Done |
| CMMI | Proposed → Active → Resolved → Closed |
Common pairing: transition to Resolved/Committed when the PR opens, and let the PR completion (or a manual step) move it to Closed/Done. ship-pr.ts --transition Resolved does the open-time half.
Some workflows restrict which transitions are legal from a given state; an illegal transition errors. If it fails, read the current state and pick a reachable next state.
PR completion options
ship opens the PR; merging is usually a human/policy gate. When you do want to drive completion:
# Set auto-complete so the PR merges once policies pass (squash, delete source branch):
az repos pr update --id <pr_id> --auto-complete true --squash true --delete-source-branch true \
--organization <org_url>
# Add reviewers:
az repos pr reviewer add --id <pr_id> --reviewers <upn-or-id> --organization <org_url>Merge strategies: --squash, --merge (no-FF, default), --rebase, --rebase-merge.
Tags (PR labels)
Azure DevOps PR tags are the same primitive the REST API calls labels (WebApiTagDefinition). They surface in the PR "Tags" panel and exist to "communicate extra information to reviewers, such as that the PR is still a work in progress, or is a hotfix for an upcoming release" (Add tags to a pull request). They are advisory triage signals — independent of work-item links and of Board state.
ship-tag.ts uses the azure-devops-node-api IGitApi label methods:
| Action | SDK call |
|---|---|
| Add | git.createPullRequestLabel({ name }, repoId, prId, project) → WebApiTagDefinition |
| List | git.getPullRequestLabels(repoId, prId, project) → WebApiTagDefinition[] |
| Remove | git.deletePullRequestLabels(repoId, prId, labelIdOrName, project) |
Tags are free-form: the definition is created on first use, so there's no pre-registration step. Re-adding an existing tag returns the existing definition (not an error); removing a tag the PR doesn't carry returns 404 (treated as non-fatal). Auth reuses the PAT→az OAuth-bearer fallback — an under-scoped PAT (Code-only, missing Pull Request write) 401s, and the script retries with the bearer.
# At PR open:
bun scripts/ship-pr.ts --title "Hotfix: cert rotation" --body-file /tmp/pr-body.md \
--work-item 794 --transition Resolved --tag "hotfix,do-not-merge"
# On an existing PR (numeric pr_id from ship-pr.ts):
bun scripts/ship-tag.ts 41666 --add "work-in-progress"
bun scripts/ship-tag.ts 41666 --remove "work-in-progress" --add "needs-review"
bun scripts/ship-tag.ts 41666 --list
# Equivalent raw REST (reference only — prefer the script):
# POST {org}/{project}/_apis/git/repositories/{repo}/pullRequests/{prId}/labels?api-version=7.1
# GET …/labels DELETE …/labels/{labelIdOrName}Recommended tags (best practice)
Microsoft documents the purpose (WIP / DO-NOT-MERGE / hotfix) but leaves the vocabulary to the team. Keep the set small, lowercase, hyphenated, and consistent — tags only help triage if everyone uses the same words. A practical starter taxonomy:
| Category | Tags | Use |
|---|---|---|
| Status (MS-documented) | work-in-progress, do-not-merge | PR not ready to complete — pairs with draft PRs |
| Type | hotfix, bug, feature, chore, docs | What kind of change this is |
| Release (MS-documented) | hotfix, release-blocker, next-release | Ties the PR to a release train |
| Risk / scope | breaking-change, security, infra, db-migration | Flags that warrant extra reviewer attention |
| Workflow | needs-review, needs-rebase, blocked | Where the PR is stuck |
Guidance, distilled: tag for triage and reviewer attention, not for data you can get elsewhere — don't duplicate the linked work item, author, or target branch as tags. Prefer a draft PR + work-in-progress over a "[WIP]" title prefix. Retire do-not-merge/work-in-progress before completing the PR.
Deeper API work
For anything beyond open-PR-and-link — WIQL queries, batch work-item updates, pipeline triggers, comment threads — use the `azure-devops` skill, which wraps the REST API and MCP tools comprehensively. ship deliberately stays narrow: deliver this branch.
Gotchas
- Branch policies can block direct push to `main`. That's the signal to use the PR path, not to force-push. Never bypass a policy.
- `AZURE_DEVOPS_EXT_PAT` shadows interactive login — if it holds a stale/expired token,
az reposfails confusingly. ship-pr.ts always sets a fresh one. - Work-item link doesn't change state — linking and transitioning are independent. You need both if you want the board to move.
- `--assigned-to` must resolve to an org member — an email/UPN that isn't a project member errors (or silently leaves the item unassigned on some orgs). The default
juliano.barbosa@hypera.com.bris thehyperadevopsidentity; pass--assigneefor any other org. - Created items land in the initial state, not "Active" — a freshly created Task is
New(Agile/Scrum) orTo Do(Basic). If a policy expects in-progress work, add--transition. - Server-side attribution stripping is not guaranteed across orgs — never add AI attribution in the first place (see SKILL.md).
Ship — GitHub path
Detail for the GitHub half of ship. Read this when the remote is github.com (or a custom SSH host alias for GitHub).
Auth
The GitHub path creates the PR with Octokit (@octokit/rest). It needs a token, resolved in order: GH_TOKEN, GITHUB_TOKEN, then gh auth token (so an existing gh auth login just works). The push step (ship-push.ts) still uses git — there's no OAuth-Bearer fallback like Azure, so if git push fails, fix the credential (gh auth login / SSH key) rather than working around it.
Custom SSH host aliases: this very repo uses git@github-julianomb:julianobarbosa/claude-code-skills.git. The host (github-julianomb) doesn't contain github.com, so ship-detect.ts falls back to matching the substring github. If detection ever returns unknown, set SHIP_PLATFORM=github for the command.
Opening the PR
ship-pr.ts calls octokit.pulls.create({ owner, repo, base, head, title, body, draft }), parsing owner/repo from the remote URL. Pass --body or --body-file for the description; unlike the old gh pr create --fill, there is no commit-message auto-fill — with neither flag the body is empty. Add --draft for a draft PR.
Linking issues
GitHub has no AB# syntax. Use closing keywords in the PR body so the issue closes on merge:
Closes #123
Fixes #123There is no GitHub equivalent of the Azure "transition Board state" step — --transition is ignored on GitHub. Issue state follows the closing keyword at merge time.
Labels (the GitHub equivalent of Azure "tags")
What Azure DevOps calls PR tags, GitHub calls labels. Because a PR is an issue on GitHub, labels go through the issues API, keyed by the PR number (printed as pr_number= by ship-pr.ts):
| Action | Octokit call |
|---|---|
| Add | octokit.issues.addLabels({ owner, repo, issue_number, labels }) |
| List | octokit.issues.listLabelsOnIssue({ owner, repo, issue_number }) |
| Remove | octokit.issues.removeLabel({ owner, repo, issue_number, name }) |
A label that doesn't exist on the repo is auto-created (with a random color) when first added — so there's no pre-registration step, the same as Azure's free-form tags. Re-adding an existing label is a no-op (GitHub dedupes); removeLabel on a label the PR doesn't carry 404s (treated as non-fatal).
# At PR open:
bun scripts/ship-pr.ts --title "Add report company filter" --body-file /tmp/pr-body.md \
--tag "feature,needs-review"
# On an existing PR (the pr_number from ship-pr.ts):
bun scripts/ship-tag.ts 123 --add "do-not-merge"
bun scripts/ship-tag.ts 123 --remove "do-not-merge" --listRecommended labels (best practice)
The same small, consistent vocabulary recommended for Azure applies — GitHub's default labels (bug, enhancement, documentation, good first issue, help wanted) are a fine base. Keep them lowercase and hyphenated; lean on do-not-merge / work-in-progress for status and reserve labels for triage, not for data already carried by the linked issue or the target branch. The full category table lives in references/azure-devops.md → Recommended tags and is platform-agnostic.
Auto-merge
gh pr merge --auto --squash --delete-branchQueues the merge to happen once required checks pass, then deletes the branch. Honors branch protection — it won't bypass required reviews or status checks.
Gotchas
- Octokit `pulls.create` needs the branch pushed first (the
headref must exist on the remote).ship's flow pushes (step 3) before opening the PR (step 4); don't reorder. - `head` must match the pushed branch name, not a worktree directory name.
- No AI attribution in title or body (see SKILL.md). With no
--body/--body-filethe PR body is empty — there's no commit auto-fill, so write a real description. - `auto-merge` is still a `gh` command (
gh pr merge --auto …) — the Octokit path only opens the PR; cleanup/merge stays as documented above.
#!/usr/bin/env bun
// Print the delivery platform and coordinates for a remote, as key=value lines.
// Usage: bun ship-detect.ts [remote] (default remote: origin)
import { currentBranch, remoteUrl, detectKind, adoParts, parseWorkItem } from "./ship-lib.ts";
const remote = process.argv[2] ?? "origin";
const url = remoteUrl(remote);
const kind = detectKind(url);
console.log(`platform=${kind}`);
console.log(`remote=${remote}`);
console.log(`url=${url}`);
console.log(`branch=${currentBranch()}`);
if (kind === "azure") {
const parts = adoParts(url);
if (parts) {
console.log(`org_url=${parts.orgUrl}`);
console.log(`project=${parts.project}`);
console.log(`repo=${parts.repo}`);
}
}
const wi = parseWorkItem();
if (wi) console.log(`work_item=${wi}`);
// Shared helpers for the `ship` skill (TypeScript, run via bun).
// Import these; do not execute this file directly.
//
// Git/branch/remote work stays as subprocess calls — it is inherently a git
// operation, not an API one. The Azure DevOps and GitHub REST surfaces use their
// official SDKs (azure-devops-node-api, @octokit/rest). All subprocess calls go
// through execFile with an argument array — never a shell string — so nothing is
// interpolated into a shell.
import { execFileSync } from "node:child_process";
import * as azdev from "azure-devops-node-api";
export type Platform = "azure" | "github" | "unknown";
export interface AdoParts {
orgUrl: string;
project: string;
repo: string;
}
/** Run a command with an argument array (no shell). Returns trimmed stdout.
* With { allowFail: true }, a non-zero exit yields "" instead of throwing. */
export function sh(cmd: string, args: string[], opts: { allowFail?: boolean } = {}): string {
try {
return execFileSync(cmd, args, { encoding: "utf8" }).trim();
} catch (err) {
if (opts.allowFail) return "";
throw err;
}
}
/** Current checked-out branch name. */
export function currentBranch(): string {
return sh("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
}
/** Fetch URL of a remote (default: origin). Empty string if the remote is unset. */
export function remoteUrl(remote = "origin"): string {
return sh("git", ["remote", "get-url", remote], { allowFail: true });
}
/** Classify a remote URL. Override with SHIP_PLATFORM=azure|github when detection
* is wrong (e.g. a custom SSH host alias that hides the real host). */
export function detectKind(url: string = remoteUrl()): Platform {
const override = process.env.SHIP_PLATFORM;
if (override === "azure" || override === "github") return override;
if (/dev\.azure\.com|visualstudio\.com/.test(url)) return "azure";
if (/github/.test(url)) return "github";
return "unknown";
}
/** Parse an Azure DevOps remote URL into org URL / project / repo.
* Handles HTTPS (dev.azure.com), SSH (ssh.dev.azure.com:v3/...), and legacy
* *.visualstudio.com. Returns null if the URL is not an Azure DevOps URL. */
export function adoParts(url: string = remoteUrl()): AdoParts | null {
let m = url.match(/dev\.azure\.com[^:/\s]*[:/]+(?:v3\/)?([^/]+)\/([^/]+)\/(?:_git\/)?([^/]+)$/);
if (m) return { orgUrl: `https://dev.azure.com/${m[1]}`, project: m[2], repo: m[3].replace(/\.git$/, "") };
m = url.match(/([^/@.]+)\.visualstudio\.com\/([^/]+)\/(?:_git\/)?([^/]+)$/);
if (m) return { orgUrl: `https://dev.azure.com/${m[1]}`, project: m[2], repo: m[3].replace(/\.git$/, "") };
return null;
}
/** Mint a short-lived Azure DevOps OAuth access token via the logged-in az CLI.
* 499b84ac-1321-427f-aa17-267ca6975798 is the constant Azure DevOps API resource
* id (not a secret — the same for every org). Used as a git Bearer header and as
* the bearer for the azure-devops-node-api WebApi connection. */
export function adoToken(): string {
return sh("az", [
"account", "get-access-token",
"--resource", "499b84ac-1321-427f-aa17-267ca6975798",
"--query", "accessToken", "-o", "tsv",
]);
}
/** True if an SDK error is a 401/403 — the signal to fall back from a PAT to the
* az OAuth bearer (an under-scoped PAT fails write calls a full org member can make). */
export function isAuthError(e: any): boolean {
const code = e?.statusCode ?? e?.status;
if (code === 401 || code === 403) return true;
return /\((?:401|403)\)/.test(String(e?.message ?? e));
}
/** Build an Azure DevOps WebApi connection. Prefers a configured PAT
* (AZURE_DEVOPS_EXT_PAT / AZURE_DEVOPS_PAT, the standard `az devops` credential);
* otherwise mints an az OAuth bearer. `usingPat` lets the caller retry with the
* bearer on a 401/403 — see isAuthError and the authFallback pattern in ship-pr.ts. */
export function adoConnection(
orgUrl: string,
opts: { forceBearer?: boolean } = {},
): { conn: azdev.WebApi; usingPat: boolean } {
const pat = opts.forceBearer ? "" : (process.env.AZURE_DEVOPS_EXT_PAT || process.env.AZURE_DEVOPS_PAT);
const handler = pat ? azdev.getPersonalAccessTokenHandler(pat) : azdev.getBearerHandler(adoToken());
return { conn: new azdev.WebApi(orgUrl, handler), usingPat: !!pat };
}
/** Split a CSV/repeated-flag tag list into clean, de-duplicated names.
* Drops empty/whitespace entries so they never reach the label API. */
export function parseTags(values: string[]): string[] {
const out: string[] = [];
for (const v of values) {
for (const part of v.split(",")) {
const name = part.trim();
if (name && !out.includes(name)) out.push(name);
}
}
return out;
}
/** Best-effort extraction of a work-item id from a branch name or commit subject.
* Recognizes `AB#1234`, `AB1234`, and a number delimited by / _ - (e.g.
* feature/1234-foo). Returns the id or "". Heuristic — confirm it exists before
* trusting it (see adoWorkItemExists in ship-pr.ts). */
export function parseWorkItem(s: string = currentBranch()): string {
let m = s.match(/[Aa][Bb]#?(\d{1,7})/);
if (m) return m[1];
m = s.match(/(?:^|[/_-])(\d{2,7})(?:[/_-]|$)/);
if (m) return m[1];
return "";
}
#!/usr/bin/env bun
// Open a pull request on the detected platform (Azure Repos via azure-devops-node-api,
// GitHub via @octokit/rest), link work item(s), and optionally transition Board state.
//
// Usage:
// bun ship-pr.ts --title T [--body B | --body-file F] [--target main] [--source BR]
// [--work-item ID] [--task "title" ...] [--tasks-file F]
// [--assignee UPN] [--work-item-type TYPE] [--no-create-work-item]
// [--transition STATE] [--tag "t1,t2" ...] [--draft] [-r remote]
//
// Work-item association (Azure Repos + Boards):
// On Azure, the PR is always linked to at least one Board work item. The id is
// taken from --work-item (or inferred from the branch) and VERIFIED to exist. If
// no real work item is found, one is CREATED per task (each assigned to the
// configured user) and linked to the PR — so a PR is never opened without
// traceability.
//
// --work-item Existing Board id to link. Verified via the SDK; if it doesn't
// resolve, the create path runs instead.
// --task Title for a work item to create (repeatable — one item per task).
// With none given and no work item found, one item is created from
// the PR --title.
// --tasks-file File with one task title per line (combined with --task flags).
// --assignee UPN/email the created work items are assigned to. Default:
// $SHIP_ADO_ASSIGNEE, else juliano.barbosa@hypera.com.br.
// --work-item-type Type for created items (default: Task; e.g. "User Story", Bug).
// --no-create-work-item Disable auto-creation; link only a pre-existing --work-item.
// --transition Azure only: sets each linked/created item's state AFTER the PR is
// created (e.g. Resolved). Skipped on GitHub.
// --tag PR tag(s) to apply after the PR opens (Azure "tags" / GitHub
// "labels"). Comma-separated and/or repeatable. e.g. --tag hotfix,WIP.
// Or manage tags later with ship-tag.ts.
import { readFileSync } from "node:fs";
import * as azdev from "azure-devops-node-api";
import { Octokit } from "@octokit/rest";
import {
sh, currentBranch, remoteUrl, detectKind, adoParts, adoToken, parseWorkItem, parseTags,
} from "./ship-lib.ts";
function fail(msg: string, code = 1): never {
console.error(msg);
process.exit(code);
}
// ---- args ----
let title = "", body = "", bodyFile = "", target = "main", sourceBranch = "";
let workItem = "", transition = "", remote = "origin", wiType = "Task";
let assignee = process.env.SHIP_ADO_ASSIGNEE || "juliano.barbosa@hypera.com.br";
let draft = false, createWi = true;
const tasks: string[] = [];
const tagFlags: string[] = [];
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case "--title": title = argv[++i]; break;
case "--body": body = argv[++i]; break;
case "--body-file": bodyFile = argv[++i]; break;
case "--target": target = argv[++i]; break;
case "--source": sourceBranch = argv[++i]; break;
case "--work-item": workItem = argv[++i]; break;
case "--task": tasks.push(argv[++i]); break;
case "--tasks-file":
for (const line of readFileSync(argv[++i], "utf8").split("\n")) {
const t = line.trim();
if (t) tasks.push(t);
}
break;
case "--assignee": assignee = argv[++i]; break;
case "--work-item-type": wiType = argv[++i]; break;
case "--no-create-work-item": createWi = false; break;
case "--transition": transition = argv[++i]; break;
case "--tag": { const v = argv[++i]; if (v === undefined) fail("--tag requires a value", 2); tagFlags.push(v); break; }
case "--draft": draft = true; break;
case "-r": case "--remote": remote = argv[++i]; break;
case "-h": case "--help":
console.log(readFileSync(new URL(import.meta.url), "utf8")
.split("\n").filter((l) => l.startsWith("//")).map((l) => l.slice(3)).join("\n"));
process.exit(0);
default: fail(`unknown arg: ${a}`, 2);
}
}
if (!title) fail("--title is required", 2);
if (!sourceBranch) sourceBranch = currentBranch();
if (!workItem) workItem = parseWorkItem(sourceBranch);
const url = remoteUrl(remote);
const kind = detectKind(url);
if (bodyFile) body = readFileSync(bodyFile, "utf8");
const tags = parseTags(tagFlags);
async function runAzure(): Promise<void> {
const parts = adoParts(url);
if (!parts) fail(`could not parse org/project/repo from ${url}`);
const { orgUrl, project, repo } = parts!;
// Prefer a configured PAT (the standard ADO credential, used by `az devops`).
// The az-OAuth token only works when the logged-in az identity is an org member;
// when it differs from the ADO identity it resolves to anonymous (TF400813).
const pat = process.env.AZURE_DEVOPS_EXT_PAT || process.env.AZURE_DEVOPS_PAT;
const handler = pat ? azdev.getPersonalAccessTokenHandler(pat) : azdev.getBearerHandler(adoToken());
const conn = new azdev.WebApi(orgUrl, handler);
let wit = await conn.getWorkItemTrackingApi();
let git = await conn.getGitApi();
let usingPat = !!pat;
// A PAT that is present but under-scoped (e.g. Code-only, missing Work Items /
// Pull Request write) makes the write calls below 401 even though the az OAuth
// identity may be a full org member. Detect that and retry once with the bearer.
const isAuthError = (e: any): boolean => {
const code = e?.statusCode ?? e?.status;
if (code === 401 || code === 403) return true;
return /\((?:401|403)\)/.test(String(e?.message ?? e));
};
const authFallback = async <T>(op: () => Promise<T>): Promise<T> => {
try {
return await op();
} catch (e) {
if (!usingPat || !isAuthError(e)) throw e;
console.error(">> PAT auth failed (401/403); falling back to az OAuth bearer token");
const bconn = new azdev.WebApi(orgUrl, azdev.getBearerHandler(adoToken()));
wit = await bconn.getWorkItemTrackingApi();
git = await bconn.getGitApi();
usingPat = false;
return await op();
}
};
// Resolve the work item(s) to link: verify any inferred/passed id exists,
// otherwise create one per task (each assigned to `assignee`).
const wiIds: number[] = [];
const existing = workItem ? Number(workItem) : NaN;
let exists = false;
if (!Number.isNaN(existing)) {
try { exists = !!(await authFallback(() => wit.getWorkItem(existing)))?.id; } catch { exists = false; }
}
if (exists) {
wiIds.push(existing);
console.error(`>> linking existing work item ${existing}`);
} else if (createWi) {
const titles = tasks.length ? tasks : [title];
for (const t of titles) {
const document: any[] = [{ op: "add", path: "/fields/System.Title", value: t }];
if (assignee) document.push({ op: "add", path: "/fields/System.AssignedTo", value: assignee });
const created = await authFallback(() => wit.createWorkItem(null, document, project, wiType));
const id = created.id!;
console.error(`>> created ${wiType} #${id} assigned to ${assignee || "<unassigned>"}: ${t}`);
console.log(`work_item_created=${id}`);
wiIds.push(id);
}
} else if (workItem) {
console.error(`>> WARNING: work item ${workItem} not found and --no-create-work-item set; PR will not link it`);
}
// Back-fill the AB#<...> placeholder (from assets/pr-template.md) with the resolved
// id(s), so the work-item reference renders as a live link instead of literal text.
if (wiIds.length && /AB#<[^>]*>/.test(body)) {
body = body.replace(/AB#<[^>]*>/g, wiIds.map((id) => `AB#${id}`).join(" "));
console.error(`>> substituted AB# placeholder -> ${wiIds.map((id) => `AB#${id}`).join(" ")}`);
}
const pr = await authFallback(() => git.createPullRequest(
{
sourceRefName: `refs/heads/${sourceBranch}`,
targetRefName: `refs/heads/${target}`,
title,
description: body || undefined,
isDraft: draft || undefined,
workItemRefs: wiIds.map((id) => ({ id: String(id) })),
},
repo,
project,
));
const prId = pr.pullRequestId!;
console.log("platform=azure");
console.log(`pr_id=${prId}`);
console.log(`pr_url=${orgUrl}/${project}/_git/${repo}/pullrequest/${prId}`);
if (wiIds.length) console.log(`work_items=${wiIds.join(" ")}`);
if (transition && wiIds.length) {
for (const id of wiIds) {
console.error(`>> transitioning work item ${id} -> ${transition}`);
await authFallback(() => wit.updateWorkItem(null, [{ op: "add", path: "/fields/System.State", value: transition }], id, project));
console.log(`work_item=${id} state=${transition}`);
}
}
for (const name of tags) {
await authFallback(() => git.createPullRequestLabel({ name }, repo, prId, project));
console.log(`tag_added=${name}`);
}
}
async function runGitHub(): Promise<void> {
const m = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (!m) fail(`could not parse owner/repo from ${url}`);
const [, owner, repo] = m!;
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || sh("gh", ["auth", "token"], { allowFail: true });
if (!token) fail("no GitHub token: set GH_TOKEN/GITHUB_TOKEN or run `gh auth login`");
const octokit = new Octokit({ auth: token });
const { data } = await octokit.pulls.create({
owner, repo, base: target, head: sourceBranch, title,
body: body || undefined, draft,
});
console.log("platform=github");
console.log(`pr_url=${data.html_url}`);
console.log(`pr_number=${data.number}`);
if (tags.length) {
// A PR is an issue on GitHub; labels go through the issues API. A missing
// label is auto-created on the repo (random color).
await octokit.issues.addLabels({ owner, repo, issue_number: data.number, labels: tags });
for (const name of tags) console.log(`tag_added=${name}`);
}
}
async function main(): Promise<void> {
switch (kind) {
case "azure": await runAzure(); break;
case "github": await runGitHub(); break;
default: fail(`unknown platform for remote '${remote}' (${url}); set SHIP_PLATFORM=azure|github`);
}
}
main().catch((err) => fail(err?.message ? `ship-pr failed: ${err.message}` : String(err)));
#!/usr/bin/env bun
// Push the current (or named) branch and set upstream.
// On Azure DevOps, if the normal push fails on auth, mint an OAuth token and
// retry with a Bearer header — the fallback for when SSH and the HTTPS
// credential helper are both unavailable but `az` is logged in.
//
// Usage: bun ship-push.ts [-r remote] [-b branch]
import { execFileSync } from "node:child_process";
import { currentBranch, remoteUrl, detectKind, adoToken } from "./ship-lib.ts";
function fail(msg: string, code = 1): never {
console.error(msg);
process.exit(code);
}
// Run git with inherited stdio so push progress streams. Returns true on success.
function git(args: string[]): boolean {
try {
execFileSync("git", args, { stdio: "inherit" });
return true;
} catch {
return false;
}
}
let remote = "origin";
let branch = "";
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
switch (argv[i]) {
case "-r": case "--remote": remote = argv[++i]; break;
case "-b": case "--branch": branch = argv[++i]; break;
case "-h": case "--help":
console.log("Usage: bun ship-push.ts [-r remote] [-b branch]");
process.exit(0);
default: fail(`unknown arg: ${argv[i]}`, 2);
}
}
if (!branch) branch = currentBranch();
const url = remoteUrl(remote);
const kind = detectKind(url);
console.error(`>> pushing ${branch} -> ${remote} (${kind})`);
if (git(["push", "-u", remote, branch])) {
console.log("push_ok=normal");
process.exit(0);
}
if (kind !== "azure") {
fail("push failed and remote is not Azure DevOps; not attempting OAuth fallback");
}
console.error(">> normal push failed; trying Azure DevOps OAuth Bearer fallback");
let token: string;
try {
token = adoToken();
} catch {
fail("failed to mint token (is 'az login' done?)");
}
if (!token) fail("failed to mint token (is 'az login' done?)");
// Token is passed as a request header, never in the URL. It is short-lived (~60 min).
if (!git(["-c", `http.extraHeader=Authorization: Bearer ${token}`, "push", "-u", remote, branch])) {
fail("OAuth Bearer push also failed");
}
console.log("push_ok=oauth-bearer");
#!/usr/bin/env bun
// Save the current state as an annotated git tag — a lightweight checkpoint you
// can return to. The tag points at HEAD; the working tree doesn't matter.
//
// The name auto-fills from the date so it's never stale, and the first message
// paragraph auto-fills branch + short hash so the tag is self-describing even if
// you pass no message. Extra -m paragraphs (what changed, verification) are
// yours to supply. This is a *git tag* (a commit checkpoint), distinct from
// ship-tag.ts, which manages PR labels/tags — different concept entirely.
//
// Usage:
// bun ship-snapshot.ts [-m "para"]... [--daily] [--name <tag>] [--push] [--annotate-note "..."]
//
// -m, --message "<text>" Extra message paragraph (repeatable). Each becomes
// its own blank-line-separated paragraph in the tag.
// --daily Name = v<YYYY.MM.DD> (one tag/day). Default includes
// -HHMM so multiple snapshots/day don't collide.
// --name <tag> Use this exact tag name, ignoring the date scheme.
// --push Push the tag to origin after creating it (Azure
// OAuth Bearer fallback on auth failure, like ship-push).
// -h, --help Show usage.
//
// Output (stdout): tag=<name> commit=<shorthash> branch=<branch> pushed=<yes|no>
import { execFileSync } from "node:child_process";
import { sh, currentBranch, remoteUrl, detectKind, adoToken } from "./ship-lib.ts";
function fail(msg: string, code = 1): never {
console.error(msg);
process.exit(code);
}
// git with inherited stdio (push progress streams). Returns true on success.
function gitStreamed(args: string[]): boolean {
try {
execFileSync("git", args, { stdio: "inherit" });
return true;
} catch {
return false;
}
}
let daily = false;
let push = false;
let name = "";
const messages: string[] = [];
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
switch (argv[i]) {
case "-m": case "--message": messages.push(argv[++i]); break;
case "--daily": daily = true; break;
case "--name": name = argv[++i]; break;
case "--push": push = true; break;
case "-h": case "--help":
console.log(
'Usage: bun ship-snapshot.ts [-m "para"]... [--daily] [--name <tag>] [--push]',
);
process.exit(0);
default: fail(`unknown arg: ${argv[i]}`, 2);
}
}
// Must be inside a git repo with a commit to point at.
const head = sh("git", ["rev-parse", "--short", "HEAD"], { allowFail: true });
if (!head) fail("not a git repo, or no commits yet — nothing to snapshot", 2);
const branch = currentBranch();
// Tag name: explicit --name wins; otherwise date-based. `date` (not JS Date) so
// the output matches the shell pattern and respects the machine's local TZ.
if (!name) {
const fmt = daily ? "+v%Y.%m.%d" : "+v%Y.%m.%d-%H%M";
name = sh("date", [fmt]);
}
// Refuse to clobber an existing tag — a snapshot must never silently move a tag
// someone else relies on. With the default minute-granular name this only bites
// on two snapshots in the same minute; --daily collides for the rest of the day.
if (sh("git", ["rev-parse", "-q", "--verify", `refs/tags/${name}`], { allowFail: true })) {
fail(
`tag '${name}' already exists. Use --name <other>, or drop --daily for minute granularity.`,
3,
);
}
// Auto subject makes the tag self-describing even with no -m. Extra paragraphs
// follow. execFileSync (via sh) takes an arg array — no shell — so newlines and
// punctuation in messages are passed through verbatim, never re-interpreted.
const subject = `Snapshot ${name} — ${branch} @ ${head}`;
const tagArgs = ["tag", "-a", name, "-m", subject];
for (const m of messages) tagArgs.push("-m", m);
sh("git", tagArgs);
console.error(`>> created annotated tag ${name} -> ${head} (${branch})`);
let pushed = "no";
if (push) {
const remote = "origin";
const kind = detectKind(remoteUrl(remote));
console.error(`>> pushing tag ${name} -> ${remote} (${kind})`);
if (gitStreamed(["push", remote, name])) {
pushed = "yes";
} else if (kind === "azure") {
// Mirror ship-push: under-scoped/absent git creds but `az` logged in.
console.error(">> normal push failed; trying Azure DevOps OAuth Bearer fallback");
let token = "";
try { token = adoToken(); } catch { /* handled below */ }
if (!token) fail("tag created locally, but push failed: could not mint token (is 'az login' done?)");
if (!gitStreamed(["-c", `http.extraHeader=Authorization: Bearer ${token}`, "push", remote, name])) {
fail(`tag '${name}' created locally, but OAuth Bearer push also failed`);
}
pushed = "yes-oauth-bearer";
} else {
fail(`tag '${name}' created locally, but push failed (remote is ${kind}; no OAuth fallback)`);
}
}
console.log(`tag=${name}`);
console.log(`commit=${head}`);
console.log(`branch=${branch}`);
console.log(`pushed=${pushed}`);
#!/usr/bin/env bun
// Manage tags (Azure DevOps "PR tags" / GitHub PR "labels") on a pull request.
// Tags communicate at-a-glance state to reviewers — work-in-progress, DO NOT
// MERGE, hotfix, area/type — per Microsoft's "Add tags to a pull request" guidance
// (https://learn.microsoft.com/azure/devops/repos/git/pull-requests#add-tags-to-a-pull-request).
//
// Usage:
// bun ship-tag.ts <pr-id> --add "tag1,tag2" [--add tag3 ...]
// bun ship-tag.ts <pr-id> --remove "tag1" [--remove tag2 ...]
// bun ship-tag.ts <pr-id> --list
// bun ship-tag.ts --pr <pr-id> --add hotfix [-r remote]
//
// <pr-id> The pull request id. Azure: the numeric PR id (ship-pr.ts prints
// `pr_id=`). GitHub: the PR number (ship-pr.ts prints `pr_number=`).
// --add T Tag(s) to add. Comma-separated and/or repeatable. Empty/blank
// names are dropped. ADO creates the tag definition if new; GitHub
// auto-creates a missing label (random color) on the repo.
// --remove T Tag(s) to remove. Comma-separated and/or repeatable.
// --list Print the PR's current tags, one per line (default if no --add/--remove).
// -r, --remote Git remote to resolve coordinates from (default: origin).
//
// Tags are visible to the team but reversible and low-blast — no destructive
// confirm. Auth mirrors ship-pr.ts: a configured PAT, falling back to an az OAuth
// bearer on a 401/403 (an under-scoped PAT can't write labels a full member can).
import { readFileSync } from "node:fs";
import { Octokit } from "@octokit/rest";
import {
sh, remoteUrl, detectKind, adoParts, adoConnection, isAuthError, parseTags,
} from "./ship-lib.ts";
function fail(msg: string, code = 1): never {
console.error(msg);
process.exit(code);
}
// ---- args ----
let prId = "", remote = "origin", list = false;
const addFlags: string[] = [];
const removeFlags: string[] = [];
const argv = process.argv.slice(2);
let i = 0;
const take = (flag: string): string => {
const v = argv[++i];
if (v === undefined) fail(`${flag} requires a value`, 2);
return v;
};
for (; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case "--pr": prId = take(a); break;
case "--add": addFlags.push(take(a)); break;
case "--remove": removeFlags.push(take(a)); break;
case "--list": list = true; break;
case "-r": case "--remote": remote = take(a); break;
case "-h": case "--help":
console.log(readFileSync(new URL(import.meta.url), "utf8")
.split("\n").filter((l) => l.startsWith("//")).map((l) => l.slice(3)).join("\n"));
process.exit(0);
default:
if (!prId && /^\d+$/.test(a)) prId = a;
else fail(`unknown arg: ${a}`, 2);
}
}
if (!prId) fail("a pull request id is required (positional or --pr)", 2);
const addTags = parseTags(addFlags);
const removeTags = parseTags(removeFlags);
if (!addTags.length && !removeTags.length) list = true; // default action
const url = remoteUrl(remote);
const kind = detectKind(url);
async function runAzure(): Promise<void> {
const parts = adoParts(url);
if (!parts) fail(`could not parse org/project/repo from ${url}`);
const { orgUrl, project, repo } = parts!;
const id = Number(prId);
let { conn, usingPat } = adoConnection(orgUrl);
let git = await conn.getGitApi();
const authFallback = async <T>(op: () => Promise<T>): Promise<T> => {
try {
return await op();
} catch (e) {
if (!usingPat || !isAuthError(e)) throw e;
console.error(">> PAT auth failed (401/403); falling back to az OAuth bearer token");
({ conn } = adoConnection(orgUrl, { forceBearer: true }));
git = await conn.getGitApi();
usingPat = false;
return await op();
}
};
for (const name of addTags) {
await authFallback(() => git.createPullRequestLabel({ name }, repo, id, project));
console.log(`tag_added=${name}`);
}
for (const name of removeTags) {
try {
await authFallback(() => git.deletePullRequestLabels(repo, id, name, project));
console.log(`tag_removed=${name}`);
} catch (e: any) {
// a tag that isn't on the PR yields 404 — non-fatal
console.error(`>> could not remove '${name}': ${e?.message ?? e}`);
}
}
if (list) {
const labels = await authFallback(() => git.getPullRequestLabels(repo, id, project));
console.log("platform=azure");
for (const l of labels) if (l.name) console.log(l.name);
}
}
async function runGitHub(): Promise<void> {
const m = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (!m) fail(`could not parse owner/repo from ${url}`);
const [, owner, repo] = m!;
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || sh("gh", ["auth", "token"], { allowFail: true });
if (!token) fail("no GitHub token: set GH_TOKEN/GITHUB_TOKEN or run `gh auth login`");
const octokit = new Octokit({ auth: token });
const issue_number = Number(prId); // a PR is an issue on GitHub
if (addTags.length) {
await octokit.issues.addLabels({ owner, repo, issue_number, labels: addTags });
for (const name of addTags) console.log(`tag_added=${name}`);
}
for (const name of removeTags) {
try {
await octokit.issues.removeLabel({ owner, repo, issue_number, name });
console.log(`tag_removed=${name}`);
} catch (e: any) {
console.error(`>> could not remove '${name}': ${e?.message ?? e}`);
}
}
if (list) {
const { data } = await octokit.issues.listLabelsOnIssue({ owner, repo, issue_number });
console.log("platform=github");
for (const l of data) console.log(l.name);
}
}
async function main(): Promise<void> {
switch (kind) {
case "azure": await runAzure(); break;
case "github": await runGitHub(); break;
default: fail(`unknown platform for remote '${remote}' (${url}); set SHIP_PLATFORM=azure|github`);
}
}
main().catch((err) => fail(err?.message ? `ship-tag failed: ${err.message}` : String(err)));
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["scripts/**/*.ts"]
}