@thanaen/ado-cli 0.2.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
@@ -57187,6 +57187,7 @@ import { homedir } from "node:os";
57187
57187
  var DEFAULT_COLLECTION_URL = "https://dev.azure.com/<your-org>";
57188
57188
  var DEFAULT_PROJECT = "<your-project>";
57189
57189
  var DEFAULT_REPO = "<your-repository>";
57190
+ var LOCAL_CONFIG_FILENAME = "ado.json";
57190
57191
  function isDefaultPlaceholder(value) {
57191
57192
  return value.includes("<your-");
57192
57193
  }
@@ -57211,23 +57212,46 @@ function loadFileConfig() {
57211
57212
  return {};
57212
57213
  }
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
+ }
57214
57237
  function getConfig() {
57215
57238
  const fileConfig = loadFileConfig();
57216
- const pat = process.env.DEVOPS_PAT ?? fileConfig.pat;
57239
+ const localConfig = loadLocalConfig();
57240
+ const pat = process.env.DEVOPS_PAT ?? localConfig.pat ?? fileConfig.pat;
57217
57241
  if (!pat) {
57218
57242
  console.error("Missing DEVOPS_PAT environment variable or pat in config file.");
57219
57243
  console.error(`Run "ado init" to create a config file at ${getConfigFilePath()}`);
57220
57244
  process.exit(1);
57221
57245
  }
57222
- const collectionUrl = process.env.ADO_COLLECTION_URL ?? fileConfig.collectionUrl ?? DEFAULT_COLLECTION_URL;
57223
- const project = process.env.ADO_PROJECT ?? fileConfig.project ?? DEFAULT_PROJECT;
57224
- const repo = process.env.ADO_REPO ?? fileConfig.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;
57225
57249
  if (isDefaultPlaceholder(collectionUrl) || isDefaultPlaceholder(project) || isDefaultPlaceholder(repo)) {
57226
57250
  console.error("ADO configuration is incomplete. Set ADO_COLLECTION_URL, ADO_PROJECT, and ADO_REPO.");
57227
57251
  console.error(`You can also run "ado init" to create a config file at ${getConfigFilePath()}`);
57228
57252
  process.exit(1);
57229
57253
  }
57230
- const insecure = process.env.ADO_INSECURE === "1" || fileConfig.insecure === true;
57254
+ const insecure = process.env.ADO_INSECURE === "1" || localConfig.insecure === true || fileConfig.insecure === true;
57231
57255
  if (insecure) {
57232
57256
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
57233
57257
  }
@@ -57341,6 +57365,42 @@ function buildRecentWorkItemsWiql(filters = {}) {
57341
57365
  return `SELECT [System.Id] FROM WorkItems${whereClause} ORDER BY [System.ChangedDate] DESC`;
57342
57366
  }
57343
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
+
57344
57404
  // src/cli.ts
57345
57405
  var import_GitInterfaces = __toESM(require_GitInterfaces(), 1);
57346
57406
  var import_WorkItemTrackingInterfaces = __toESM(require_WorkItemTrackingInterfaces(), 1);
@@ -57766,43 +57826,137 @@ async function cmdPrUpdate(config, idRaw, args) {
57766
57826
  await linkWorkItemsToPr(config, repo, updated, workItemIds);
57767
57827
  }
57768
57828
  }
57769
- async function cmdInit() {
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");
57770
57879
  const { createInterface } = await import("node:readline/promises");
57771
57880
  const { mkdirSync, writeFileSync, existsSync: existsSync2 } = await import("node:fs");
57772
57881
  const rl = createInterface({ input: process.stdin, output: process.stdout });
57773
- const existing = loadFileConfig();
57774
- const configPath = getConfigFilePath();
57775
- console.log("Azure DevOps CLI — Configuration");
57882
+ const existing = isLocal ? loadLocalConfig() : loadFileConfig();
57883
+ const configPath = isLocal ? getLocalConfigFilePath() : getConfigFilePath();
57884
+ console.log(`Azure DevOps CLI — ${isLocal ? "Local " : ""}Configuration`);
57776
57885
  console.log(`Config file: ${configPath}`);
57777
57886
  console.log(`Press Enter to keep existing values shown in brackets.
57778
57887
  `);
57779
- const pat = await rl.question(`Personal Access Token (PAT)${existing.pat ? " [****]" : ""}: `) || existing.pat || "";
57780
- const collectionUrl = await rl.question(`Collection URL${existing.collectionUrl ? ` [${existing.collectionUrl}]` : ""}: `) || existing.collectionUrl || "";
57781
- const project = await rl.question(`Project${existing.project ? ` [${existing.project}]` : ""}: `) || existing.project || "";
57782
- const repo = await rl.question(`Repository${existing.repo ? ` [${existing.repo}]` : ""}: `) || existing.repo || "";
57783
- const insecureInput = await rl.question(`Disable TLS verification (insecure)? (y/N)${existing.insecure ? " [y]" : ""}: `) || (existing.insecure ? "y" : "n");
57784
- const insecure = insecureInput.toLowerCase() === "y" || insecureInput === "1";
57785
- rl.close();
57786
- if (!pat || !collectionUrl || !project || !repo) {
57787
- console.error(`
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(`
57788
57919
  All fields (PAT, Collection URL, Project, Repository) are required.`);
57789
- process.exit(1);
57790
- }
57791
- const config = { pat, collectionUrl, project, repo, insecure };
57792
- const configDir = getConfigDir();
57793
- if (!existsSync2(configDir)) {
57794
- mkdirSync(configDir, { recursive: true });
57795
- }
57796
- writeFileSync(configPath, JSON.stringify(config, null, 2) + `
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) + `
57797
57928
  `, "utf8");
57798
- console.log(`
57929
+ console.log(`
57799
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));
57800
57952
  }
57801
57953
  function printHelp() {
57802
57954
  console.log(`Azure DevOps CLI
57803
57955
 
57804
57956
  Commands:
57805
- init
57957
+ -v, --version
57958
+ init [--local]
57959
+ config
57806
57960
  smoke
57807
57961
  repos
57808
57962
  branches [repo]
@@ -57815,19 +57969,33 @@ Commands:
57815
57969
  pr-get <id> [repo]
57816
57970
  pr-create --title=... --source=... --target=... [--description=...] [--repo=...] [--work-items=123,456]
57817
57971
  pr-update <id> [--title=...] [--description=...] [--repo=...] [--work-items=123,456]
57972
+ pr-cherry-pick <id> --target=... [--topic=branch-name] [--repo=...]
57818
57973
  pr-approve <id> [repo]
57819
57974
  pr-autocomplete <id> [repo]
57820
57975
  builds [top]
57821
57976
  `);
57822
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
+ }
57823
57983
  async function main() {
57824
57984
  const [command = "smoke", ...args] = process.argv.slice(2);
57825
57985
  if (command === "help" || command === "--help" || command === "-h") {
57826
57986
  printHelp();
57827
57987
  return;
57828
57988
  }
57989
+ if (command === "-v" || command === "--version") {
57990
+ await printVersion();
57991
+ return;
57992
+ }
57829
57993
  if (command === "init") {
57830
- await cmdInit();
57994
+ await cmdInit(args);
57995
+ return;
57996
+ }
57997
+ if (command === "config") {
57998
+ cmdConfig();
57831
57999
  return;
57832
58000
  }
57833
58001
  const config = getConfig();
@@ -57868,6 +58036,9 @@ async function main() {
57868
58036
  case "pr-update":
57869
58037
  await cmdPrUpdate(config, args[0], args.slice(1));
57870
58038
  break;
58039
+ case "pr-cherry-pick":
58040
+ await cmdPrCherryPick(config, args);
58041
+ break;
57871
58042
  case "pr-approve":
57872
58043
  await cmdPrApprove(config, args[0], args[1]);
57873
58044
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thanaen/ado-cli",
3
- "version": "0.2.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`