@thanaen/ado-cli 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,8 +37,8 @@ On-prem / localserver example:
37
37
  ```bash
38
38
  export DEVOPS_PAT="***"
39
39
  export ADO_COLLECTION_URL="https://localserver/DefaultCollection"
40
- export ADO_PROJECT="UserLock"
41
- export ADO_REPO="Ulysse Interface"
40
+ export ADO_PROJECT="ExampleProject"
41
+ export ADO_REPO="Example Repository"
42
42
  export ADO_INSECURE=1
43
43
  ```
44
44
 
@@ -59,6 +59,9 @@ ado smoke
59
59
 
60
60
  ## Commands
61
61
 
62
+ - `-v`, `--version`
63
+ - `init [--local]`
64
+ - `config`
62
65
  - `smoke`
63
66
  - `repos`
64
67
  - `branches [repo]`
@@ -71,6 +74,7 @@ ado smoke
71
74
  - `pr-get <id> [repo]`
72
75
  - `pr-create --title=... --source=... --target=... [--description=...] [--repo=...] [--work-items=123,456]`
73
76
  - `pr-update <id> [--title=...] [--description=...] [--repo=...] [--work-items=123,456]`
77
+ - `pr-cherry-pick <id> --target=... [--topic=branch-name] [--repo=...]`
74
78
  - `pr-approve <id> [repo]`
75
79
  - `pr-autocomplete <id> [repo]`
76
80
  - `builds [top]`
package/dist/cli.js CHANGED
@@ -57181,27 +57181,78 @@ import { readFile } from "node:fs/promises";
57181
57181
 
57182
57182
  // src/config.ts
57183
57183
  var import_azure_devops_node_api = __toESM(require_WebApi(), 1);
57184
+ import { readFileSync, existsSync } from "node:fs";
57185
+ import { join } from "node:path";
57186
+ import { homedir } from "node:os";
57184
57187
  var DEFAULT_COLLECTION_URL = "https://dev.azure.com/<your-org>";
57185
57188
  var DEFAULT_PROJECT = "<your-project>";
57186
57189
  var DEFAULT_REPO = "<your-repository>";
57190
+ var LOCAL_CONFIG_FILENAME = "ado.json";
57187
57191
  function isDefaultPlaceholder(value) {
57188
57192
  return value.includes("<your-");
57189
57193
  }
57194
+ function getConfigDir() {
57195
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
57196
+ const base = xdgConfig && xdgConfig.length > 0 ? xdgConfig : join(homedir(), ".config");
57197
+ return join(base, "ado");
57198
+ }
57199
+ function getConfigFilePath() {
57200
+ return join(getConfigDir(), "config.json");
57201
+ }
57202
+ function loadFileConfig() {
57203
+ const configPath = getConfigFilePath();
57204
+ if (!existsSync(configPath)) {
57205
+ return {};
57206
+ }
57207
+ const content = readFileSync(configPath, "utf8");
57208
+ try {
57209
+ return JSON.parse(content);
57210
+ } catch {
57211
+ console.error(`Warning: could not parse config file at ${configPath}. Using defaults.`);
57212
+ return {};
57213
+ }
57214
+ }
57215
+ function getLocalConfigFilePath() {
57216
+ return join(process.cwd(), LOCAL_CONFIG_FILENAME);
57217
+ }
57218
+ function loadLocalConfig() {
57219
+ const localPath = getLocalConfigFilePath();
57220
+ if (!existsSync(localPath)) {
57221
+ return {};
57222
+ }
57223
+ const content = readFileSync(localPath, "utf8");
57224
+ try {
57225
+ return JSON.parse(content);
57226
+ } catch {
57227
+ console.error(`Warning: could not parse local config file at ${localPath}. Please check the JSON syntax. Ignoring.`);
57228
+ return {};
57229
+ }
57230
+ }
57231
+ function censorPat(pat) {
57232
+ if (pat.length <= 8) {
57233
+ return "****";
57234
+ }
57235
+ return `${pat.slice(0, 4)}${"*".repeat(pat.length - 8)}${pat.slice(-4)}`;
57236
+ }
57190
57237
  function getConfig() {
57191
- const pat = process.env.DEVOPS_PAT;
57238
+ const fileConfig = loadFileConfig();
57239
+ const localConfig = loadLocalConfig();
57240
+ const pat = process.env.DEVOPS_PAT ?? localConfig.pat ?? fileConfig.pat;
57192
57241
  if (!pat) {
57193
- console.error("Missing DEVOPS_PAT environment variable.");
57242
+ console.error("Missing DEVOPS_PAT environment variable or pat in config file.");
57243
+ console.error(`Run "ado init" to create a config file at ${getConfigFilePath()}`);
57194
57244
  process.exit(1);
57195
57245
  }
57196
- const collectionUrl = process.env.ADO_COLLECTION_URL ?? DEFAULT_COLLECTION_URL;
57197
- const project = process.env.ADO_PROJECT ?? DEFAULT_PROJECT;
57198
- const repo = process.env.ADO_REPO ?? DEFAULT_REPO;
57246
+ const collectionUrl = process.env.ADO_COLLECTION_URL ?? localConfig.collectionUrl ?? fileConfig.collectionUrl ?? DEFAULT_COLLECTION_URL;
57247
+ const project = process.env.ADO_PROJECT ?? localConfig.project ?? fileConfig.project ?? DEFAULT_PROJECT;
57248
+ const repo = process.env.ADO_REPO ?? localConfig.repo ?? fileConfig.repo ?? DEFAULT_REPO;
57199
57249
  if (isDefaultPlaceholder(collectionUrl) || isDefaultPlaceholder(project) || isDefaultPlaceholder(repo)) {
57200
57250
  console.error("ADO configuration is incomplete. Set ADO_COLLECTION_URL, ADO_PROJECT, and ADO_REPO.");
57201
- console.error('Example: ADO_COLLECTION_URL="https://localserver/DefaultCollection" ADO_PROJECT="UserLock" ADO_REPO="Ulysse Interface"');
57251
+ console.error(`You can also run "ado init" to create a config file at ${getConfigFilePath()}`);
57202
57252
  process.exit(1);
57203
57253
  }
57204
- if (process.env.ADO_INSECURE === "1") {
57254
+ const insecure = process.env.ADO_INSECURE === "1" || localConfig.insecure === true || fileConfig.insecure === true;
57255
+ if (insecure) {
57205
57256
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
57206
57257
  }
57207
57258
  const authHandler = import_azure_devops_node_api.getPersonalAccessTokenHandler(pat);
@@ -57314,6 +57365,42 @@ function buildRecentWorkItemsWiql(filters = {}) {
57314
57365
  return `SELECT [System.Id] FROM WorkItems${whereClause} ORDER BY [System.ChangedDate] DESC`;
57315
57366
  }
57316
57367
 
57368
+ // src/cherry-pick.ts
57369
+ function parseCherryPickArgs(args) {
57370
+ const kv = Object.fromEntries(args.filter((a) => a.startsWith("--")).map((arg) => {
57371
+ const [k, ...rest] = arg.split("=");
57372
+ return [k.replace(/^--/, ""), rest.join("=")];
57373
+ }));
57374
+ const positionals = args.filter((a) => !a.startsWith("--"));
57375
+ const prId = Number(positionals[0]);
57376
+ if (!Number.isFinite(prId) || prId <= 0) {
57377
+ throw new Error("A valid pull request ID is required as the first argument.");
57378
+ }
57379
+ const target = kv.target;
57380
+ if (!target || target.trim().length === 0) {
57381
+ throw new Error("--target is required.");
57382
+ }
57383
+ const allowedOptions = new Set(["target", "topic", "repo"]);
57384
+ for (const key of Object.keys(kv)) {
57385
+ if (!allowedOptions.has(key)) {
57386
+ throw new Error(`Unknown option: --${key}`);
57387
+ }
57388
+ }
57389
+ return {
57390
+ prId,
57391
+ target: target.trim(),
57392
+ topic: kv.topic?.trim() || undefined,
57393
+ repo: kv.repo?.trim() || undefined
57394
+ };
57395
+ }
57396
+ function buildGeneratedRefName(prId, target, topic) {
57397
+ if (topic) {
57398
+ return topic.startsWith("refs/heads/") ? topic : `refs/heads/${topic}`;
57399
+ }
57400
+ const safeBranch = target.replace(/^refs\/heads\//, "");
57401
+ return `refs/heads/cherry-pick-pr-${prId}-onto-${safeBranch}`;
57402
+ }
57403
+
57317
57404
  // src/cli.ts
57318
57405
  var import_GitInterfaces = __toESM(require_GitInterfaces(), 1);
57319
57406
  var import_WorkItemTrackingInterfaces = __toESM(require_WorkItemTrackingInterfaces(), 1);
@@ -57739,10 +57826,137 @@ async function cmdPrUpdate(config, idRaw, args) {
57739
57826
  await linkWorkItemsToPr(config, repo, updated, workItemIds);
57740
57827
  }
57741
57828
  }
57829
+ async function cmdPrCherryPick(config, args) {
57830
+ const usage = "Usage: pr-cherry-pick <id> --target=main [--topic=branch-name] [--repo=...]";
57831
+ let parsed;
57832
+ try {
57833
+ parsed = parseCherryPickArgs(args);
57834
+ } catch (error) {
57835
+ console.error(error instanceof Error ? error.message : String(error));
57836
+ console.error(usage);
57837
+ process.exit(1);
57838
+ }
57839
+ const repo = pickRepo(config, parsed.repo);
57840
+ const gitApi = await config.connection.getGitApi();
57841
+ const repository = await gitApi.getRepository(repo, config.project);
57842
+ if (!repository?.id) {
57843
+ console.error(`Repository "${repo}" not found.`);
57844
+ process.exit(1);
57845
+ }
57846
+ const targetRef = parsed.target.startsWith("refs/") ? parsed.target : `refs/heads/${parsed.target}`;
57847
+ const generatedRefName = buildGeneratedRefName(parsed.prId, parsed.target, parsed.topic);
57848
+ const cherryPick = await gitApi.createCherryPick({
57849
+ source: { pullRequestId: parsed.prId },
57850
+ ontoRefName: targetRef,
57851
+ generatedRefName,
57852
+ repository: { id: repository.id }
57853
+ }, config.project, repository.id);
57854
+ let status = cherryPick.status;
57855
+ const cherryPickId = cherryPick.cherryPickId;
57856
+ if (cherryPickId == null) {
57857
+ console.error("Cherry-pick operation could not be started.");
57858
+ process.exit(1);
57859
+ }
57860
+ const MAX_POLL_ATTEMPTS = 30;
57861
+ const POLL_INTERVAL_MS = 1000;
57862
+ for (let attempt = 0;attempt < MAX_POLL_ATTEMPTS && (status === import_GitInterfaces.GitAsyncOperationStatus.Queued || status === import_GitInterfaces.GitAsyncOperationStatus.InProgress); attempt++) {
57863
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
57864
+ const result = await gitApi.getCherryPick(config.project, cherryPickId, repository.id);
57865
+ status = result.status;
57866
+ }
57867
+ if (status === import_GitInterfaces.GitAsyncOperationStatus.Completed) {
57868
+ console.log(`Cherry-pick of PR #${parsed.prId} completed. Branch created: ${generatedRefName.replace("refs/heads/", "")}`);
57869
+ } else if (status === import_GitInterfaces.GitAsyncOperationStatus.Failed) {
57870
+ console.error(`Cherry-pick of PR #${parsed.prId} failed.`);
57871
+ process.exit(1);
57872
+ } else {
57873
+ console.error(`Cherry-pick of PR #${parsed.prId} did not complete in time (status: ${import_GitInterfaces.GitAsyncOperationStatus[status ?? 0] ?? "unknown"}).`);
57874
+ process.exit(1);
57875
+ }
57876
+ }
57877
+ async function cmdInit(args) {
57878
+ const isLocal = args.includes("--local");
57879
+ const { createInterface } = await import("node:readline/promises");
57880
+ const { mkdirSync, writeFileSync, existsSync: existsSync2 } = await import("node:fs");
57881
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
57882
+ const existing = isLocal ? loadLocalConfig() : loadFileConfig();
57883
+ const configPath = isLocal ? getLocalConfigFilePath() : getConfigFilePath();
57884
+ console.log(`Azure DevOps CLI — ${isLocal ? "Local " : ""}Configuration`);
57885
+ console.log(`Config file: ${configPath}`);
57886
+ console.log(`Press Enter to keep existing values shown in brackets.
57887
+ `);
57888
+ if (isLocal) {
57889
+ const collectionUrl = await rl.question(`Collection URL${existing.collectionUrl ? ` [${existing.collectionUrl}]` : ""}: `) || existing.collectionUrl || "";
57890
+ const project = await rl.question(`Project${existing.project ? ` [${existing.project}]` : ""}: `) || existing.project || "";
57891
+ const repo = await rl.question(`Repository${existing.repo ? ` [${existing.repo}]` : ""}: `) || existing.repo || "";
57892
+ rl.close();
57893
+ const config = {};
57894
+ if (collectionUrl)
57895
+ config.collectionUrl = collectionUrl;
57896
+ if (project)
57897
+ config.project = project;
57898
+ if (repo)
57899
+ config.repo = repo;
57900
+ if (Object.keys(config).length === 0) {
57901
+ console.error(`
57902
+ At least one field must be provided for local config.`);
57903
+ process.exit(1);
57904
+ }
57905
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + `
57906
+ `, "utf8");
57907
+ console.log(`
57908
+ Local configuration saved to ${configPath}`);
57909
+ } else {
57910
+ const pat = await rl.question(`Personal Access Token (PAT)${existing.pat ? " [****]" : ""}: `) || existing.pat || "";
57911
+ const collectionUrl = await rl.question(`Collection URL${existing.collectionUrl ? ` [${existing.collectionUrl}]` : ""}: `) || existing.collectionUrl || "";
57912
+ const project = await rl.question(`Project${existing.project ? ` [${existing.project}]` : ""}: `) || existing.project || "";
57913
+ const repo = await rl.question(`Repository${existing.repo ? ` [${existing.repo}]` : ""}: `) || existing.repo || "";
57914
+ const insecureInput = await rl.question(`Disable TLS verification (insecure)? (y/N)${existing.insecure ? " [y]" : ""}: `) || (existing.insecure ? "y" : "n");
57915
+ const insecure = insecureInput.toLowerCase() === "y" || insecureInput === "1";
57916
+ rl.close();
57917
+ if (!pat || !collectionUrl || !project || !repo) {
57918
+ console.error(`
57919
+ All fields (PAT, Collection URL, Project, Repository) are required.`);
57920
+ process.exit(1);
57921
+ }
57922
+ const config = { pat, collectionUrl, project, repo, insecure };
57923
+ const configDir = getConfigDir();
57924
+ if (!existsSync2(configDir)) {
57925
+ mkdirSync(configDir, { recursive: true });
57926
+ }
57927
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + `
57928
+ `, "utf8");
57929
+ console.log(`
57930
+ Configuration saved to ${configPath}`);
57931
+ }
57932
+ }
57933
+ function cmdConfig() {
57934
+ const fileConfig = loadFileConfig();
57935
+ const localConfig = loadLocalConfig();
57936
+ const pat = process.env.DEVOPS_PAT ?? localConfig.pat ?? fileConfig.pat;
57937
+ const collectionUrl = process.env.ADO_COLLECTION_URL ?? localConfig.collectionUrl ?? fileConfig.collectionUrl;
57938
+ const project = process.env.ADO_PROJECT ?? localConfig.project ?? fileConfig.project;
57939
+ const repo = process.env.ADO_REPO ?? localConfig.repo ?? fileConfig.repo;
57940
+ const insecure = process.env.ADO_INSECURE === "1" || localConfig.insecure === true || fileConfig.insecure === true;
57941
+ console.log(JSON.stringify({
57942
+ pat: pat ? censorPat(pat) : undefined,
57943
+ collectionUrl: collectionUrl ?? undefined,
57944
+ project: project ?? undefined,
57945
+ repo: repo ?? undefined,
57946
+ insecure,
57947
+ sources: {
57948
+ globalConfig: getConfigFilePath(),
57949
+ localConfig: getLocalConfigFilePath()
57950
+ }
57951
+ }, null, 2));
57952
+ }
57742
57953
  function printHelp() {
57743
57954
  console.log(`Azure DevOps CLI
57744
57955
 
57745
57956
  Commands:
57957
+ -v, --version
57958
+ init [--local]
57959
+ config
57746
57960
  smoke
57747
57961
  repos
57748
57962
  branches [repo]
@@ -57755,17 +57969,35 @@ Commands:
57755
57969
  pr-get <id> [repo]
57756
57970
  pr-create --title=... --source=... --target=... [--description=...] [--repo=...] [--work-items=123,456]
57757
57971
  pr-update <id> [--title=...] [--description=...] [--repo=...] [--work-items=123,456]
57972
+ pr-cherry-pick <id> --target=... [--topic=branch-name] [--repo=...]
57758
57973
  pr-approve <id> [repo]
57759
57974
  pr-autocomplete <id> [repo]
57760
57975
  builds [top]
57761
57976
  `);
57762
57977
  }
57978
+ async function printVersion() {
57979
+ const pkgPath = new URL("../package.json", import.meta.url);
57980
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
57981
+ console.log(pkg.version);
57982
+ }
57763
57983
  async function main() {
57764
57984
  const [command = "smoke", ...args] = process.argv.slice(2);
57765
57985
  if (command === "help" || command === "--help" || command === "-h") {
57766
57986
  printHelp();
57767
57987
  return;
57768
57988
  }
57989
+ if (command === "-v" || command === "--version") {
57990
+ await printVersion();
57991
+ return;
57992
+ }
57993
+ if (command === "init") {
57994
+ await cmdInit(args);
57995
+ return;
57996
+ }
57997
+ if (command === "config") {
57998
+ cmdConfig();
57999
+ return;
58000
+ }
57769
58001
  const config = getConfig();
57770
58002
  switch (command) {
57771
58003
  case "smoke":
@@ -57804,6 +58036,9 @@ async function main() {
57804
58036
  case "pr-update":
57805
58037
  await cmdPrUpdate(config, args[0], args.slice(1));
57806
58038
  break;
58039
+ case "pr-cherry-pick":
58040
+ await cmdPrCherryPick(config, args);
58041
+ break;
57807
58042
  case "pr-approve":
57808
58043
  await cmdPrApprove(config, args[0], args[1]);
57809
58044
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thanaen/ado-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Lightweight Azure DevOps CLI for repos, work items, pull requests, and builds",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,9 @@ If auth is missing, stop and ask for `DEVOPS_PAT`.
19
19
 
20
20
  ## Core commands
21
21
 
22
+ - Initialize global config: `ado init`
23
+ - Initialize local (per-project) config: `ado init --local`
24
+ - Show resolved config: `ado config`
22
25
  - List repos: `ado repos`
23
26
  - List branches: `ado branches "MyRepo"`
24
27
  - Get work item: `ado workitem-get <id>`
@@ -32,6 +35,7 @@ If auth is missing, stop and ask for `DEVOPS_PAT`.
32
35
  - Get PR: `ado pr-get <id> "MyRepo"`
33
36
  - Create PR: `ado pr-create --title="..." --source="feature/x" --target="develop" --description="..." --repo="MyRepo" --work-items=123,456`
34
37
  - Update PR: `ado pr-update <id> --title="..." --description="..." --repo="MyRepo" --work-items=123,456`
38
+ - Cherry-pick PR onto another branch: `ado pr-cherry-pick <id> --target="main" --topic="cherry-pick-branch" --repo="MyRepo"`
35
39
  - Approve PR: `ado pr-approve <id> "MyRepo"`
36
40
  - Enable auto-complete: `ado pr-autocomplete <id> "MyRepo"`
37
41
  - List builds: `ado builds 10`