azure-pr-manager 1.0.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/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "azure-pr-manager",
3
+ "version": "1.0.0",
4
+ "description": "CLI para criar Pull Requests no Azure DevOps automaticamente, com fluxo git completo (commit, push, publish branch)",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "azure-pr": "src/index.js",
8
+ "apr": "src/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "init": "node src/index.js init"
13
+ },
14
+ "keywords": [
15
+ "azure-devops",
16
+ "pull-request",
17
+ "pr",
18
+ "cli",
19
+ "automation",
20
+ "git",
21
+ "devops",
22
+ "azure"
23
+ ],
24
+ "author": "Higor Santos",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/higorsantos/azure-pr-manager"
29
+ },
30
+ "homepage": "https://github.com/higorsantos/azure-pr-manager#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/higorsantos/azure-pr-manager/issues"
33
+ },
34
+ "files": [
35
+ "src/"
36
+ ],
37
+ "dependencies": {
38
+ "inquirer": "^9.2.12",
39
+ "chalk": "^5.3.0",
40
+ "node-fetch": "^3.3.2",
41
+ "ora": "^7.0.1",
42
+ "commander": "^11.1.0"
43
+ },
44
+ "type": "module",
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ }
48
+ }
package/src/api.js ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Azure DevOps REST API client
3
+ * Docs: https://learn.microsoft.com/en-us/rest/api/azure/devops/
4
+ */
5
+
6
+ export class AzureDevOpsApi {
7
+ constructor(config) {
8
+ this.organization = config.organization;
9
+ this.project = config.project;
10
+ this.repository = config.repository;
11
+ this.token = config.token;
12
+ this.baseUrl = `https://dev.azure.com/${this.organization}/${this.project}/_apis`;
13
+ this.authHeader =
14
+ "Basic " + Buffer.from(":" + this.token).toString("base64");
15
+ }
16
+
17
+ async request(path, options = {}) {
18
+ const url = `${this.baseUrl}${path}`;
19
+ const headers = {
20
+ Authorization: this.authHeader,
21
+ "Content-Type": "application/json",
22
+ ...options.headers,
23
+ };
24
+
25
+ const response = await fetch(url, { ...options, headers });
26
+
27
+ if (!response.ok) {
28
+ const body = await response.text();
29
+ throw new Error(
30
+ `Azure DevOps API error (${response.status}): ${body}`
31
+ );
32
+ }
33
+
34
+ return response.json();
35
+ }
36
+
37
+ /**
38
+ * Lista todas as branches do repositorio
39
+ */
40
+ async getBranches() {
41
+ const data = await this.request(
42
+ `/git/repositories/${this.repository}/refs?filter=heads/&api-version=7.1`
43
+ );
44
+ return data.value.map((ref) => ({
45
+ name: ref.name.replace("refs/heads/", ""),
46
+ objectId: ref.objectId,
47
+ }));
48
+ }
49
+
50
+ /**
51
+ * Lista os repositorios do projeto
52
+ */
53
+ async getRepositories() {
54
+ const data = await this.request(
55
+ `/git/repositories?api-version=7.1`
56
+ );
57
+ return data.value.map((repo) => ({
58
+ id: repo.id,
59
+ name: repo.name,
60
+ defaultBranch: (repo.defaultBranch || "").replace("refs/heads/", ""),
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * Busca usuario por email via Identity Picker API (mesmo endpoint que a UI usa).
66
+ * Retorna o localId que e o GUID aceito pelo endpoint de PR.
67
+ */
68
+ async resolveUserByEmail(email) {
69
+ // Primeiro: Identity Picker (mais confiavel, retorna localId)
70
+ try {
71
+ const url = `https://dev.azure.com/${this.organization}/_apis/IdentityPicker/Identities?api-version=7.1-preview.1`;
72
+ const response = await fetch(url, {
73
+ method: "POST",
74
+ headers: {
75
+ Authorization: this.authHeader,
76
+ "Content-Type": "application/json",
77
+ },
78
+ body: JSON.stringify({
79
+ query: email,
80
+ identityTypes: ["user"],
81
+ operationScopes: ["ims", "source"],
82
+ options: { MinResults: 1, MaxResults: 5 },
83
+ properties: [
84
+ "DisplayName",
85
+ "Mail",
86
+ "ScopeName",
87
+ ],
88
+ }),
89
+ });
90
+
91
+ if (response.ok) {
92
+ const data = await response.json();
93
+ const results = data.results?.[0]?.identities;
94
+ if (results && results.length > 0) {
95
+ // Procurar match exato por email primeiro
96
+ const emailLower = email.toLowerCase();
97
+ const exact = results.find(
98
+ (r) =>
99
+ (r.mail || "").toLowerCase() === emailLower ||
100
+ (r.signInAddress || "").toLowerCase() === emailLower
101
+ );
102
+ const identity = exact || results[0];
103
+
104
+ if (identity.localId) {
105
+ return {
106
+ id: identity.localId,
107
+ displayName: identity.displayName,
108
+ uniqueName: identity.signInAddress || email,
109
+ };
110
+ }
111
+ }
112
+ }
113
+ } catch {
114
+ // continue to fallback
115
+ }
116
+
117
+ // Fallback: Graph Members API (busca por email no escopo do projeto)
118
+ try {
119
+ const url = `https://vsaex.dev.azure.com/${this.organization}/_apis/userentitlements?$filter=name eq '${encodeURIComponent(email)}'&api-version=7.1-preview.3`;
120
+ const response = await fetch(url, {
121
+ headers: {
122
+ Authorization: this.authHeader,
123
+ "Content-Type": "application/json",
124
+ },
125
+ });
126
+
127
+ if (response.ok) {
128
+ const data = await response.json();
129
+ if (data.members && data.members.length > 0) {
130
+ const member = data.members[0];
131
+ // O campo member.id e o GUID correto da entitlement
132
+ return {
133
+ id: member.id,
134
+ displayName: member.user.displayName,
135
+ uniqueName: member.user.principalName || email,
136
+ };
137
+ }
138
+ }
139
+ } catch {
140
+ // continue to last fallback
141
+ }
142
+
143
+ // Last fallback: Identities API
144
+ const url = `https://dev.azure.com/${this.organization}/_apis/identities?searchFilter=General&filterValue=${encodeURIComponent(email)}&queryMembership=None&api-version=7.1`;
145
+ const response = await fetch(url, {
146
+ headers: {
147
+ Authorization: this.authHeader,
148
+ "Content-Type": "application/json",
149
+ },
150
+ });
151
+
152
+ if (!response.ok) {
153
+ throw new Error(`Nao foi possivel resolver o usuario: ${email}`);
154
+ }
155
+
156
+ const data = await response.json();
157
+ if (data.value && data.value.length > 0) {
158
+ const identity = data.value[0];
159
+ return {
160
+ id: identity.id,
161
+ displayName: identity.providerDisplayName || identity.displayName,
162
+ uniqueName: identity.properties?.Account?.$value || email,
163
+ };
164
+ }
165
+
166
+ throw new Error(`Usuario nao encontrado: ${email}`);
167
+ }
168
+
169
+ /**
170
+ * Resolve multiplos emails para IDs
171
+ */
172
+ async resolveReviewers(emails) {
173
+ const results = [];
174
+ for (const email of emails) {
175
+ try {
176
+ const user = await this.resolveUserByEmail(email);
177
+ results.push(user);
178
+ } catch (err) {
179
+ console.warn(` Aviso: ${err.message}`);
180
+ }
181
+ }
182
+ return results;
183
+ }
184
+
185
+ /**
186
+ * Cria um Pull Request
187
+ */
188
+ async createPullRequest({
189
+ sourceBranch,
190
+ targetBranch,
191
+ title,
192
+ description = "",
193
+ reviewers = [],
194
+ optionalReviewers = [],
195
+ isDraft = false,
196
+ autoComplete = false,
197
+ }) {
198
+ // Formatar refs
199
+ const sourceRef = sourceBranch.startsWith("refs/heads/")
200
+ ? sourceBranch
201
+ : `refs/heads/${sourceBranch}`;
202
+ const targetRef = targetBranch.startsWith("refs/heads/")
203
+ ? targetBranch
204
+ : `refs/heads/${targetBranch}`;
205
+
206
+ // Montar lista de reviewers
207
+ const allReviewers = [
208
+ ...reviewers.map((r) => ({
209
+ id: r.id,
210
+ isRequired: true,
211
+ })),
212
+ ...optionalReviewers.map((r) => ({
213
+ id: r.id,
214
+ isRequired: false,
215
+ })),
216
+ ];
217
+
218
+ const body = {
219
+ sourceRefName: sourceRef,
220
+ targetRefName: targetRef,
221
+ title,
222
+ description,
223
+ reviewers: allReviewers,
224
+ isDraft,
225
+ };
226
+
227
+ const pr = await this.request(
228
+ `/git/repositories/${this.repository}/pullrequests?api-version=7.1`,
229
+ {
230
+ method: "POST",
231
+ body: JSON.stringify(body),
232
+ }
233
+ );
234
+
235
+ if (autoComplete && pr.createdBy) {
236
+ try {
237
+ await this.request(
238
+ `/git/repositories/${this.repository}/pullrequests/${pr.pullRequestId}?api-version=7.1`,
239
+ {
240
+ method: "PATCH",
241
+ body: JSON.stringify({
242
+ autoCompleteSetBy: { id: pr.createdBy.id },
243
+ completionOptions: {
244
+ mergeStrategy: "squash",
245
+ deleteSourceBranch: true,
246
+ },
247
+ }),
248
+ }
249
+ );
250
+ } catch {
251
+ // auto-complete is optional, don't fail the PR creation
252
+ }
253
+ }
254
+
255
+ return pr;
256
+ }
257
+
258
+ /**
259
+ * Verifica se a conexao/token esta funcionando
260
+ */
261
+ async testConnection() {
262
+ try {
263
+ const data = await this.request(
264
+ `/git/repositories?api-version=7.1`
265
+ );
266
+ return {
267
+ success: true,
268
+ repos: data.count || data.value?.length || 0,
269
+ };
270
+ } catch (err) {
271
+ return { success: false, error: err.message };
272
+ }
273
+ }
274
+ }
package/src/config.js ADDED
@@ -0,0 +1,78 @@
1
+ import { readFile, writeFile, access } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ const CONFIG_FILE = ".prmanager.json";
5
+
6
+ const DEFAULT_CONFIG = {
7
+ organization: "",
8
+ project: "",
9
+ repository: "",
10
+ token: "",
11
+ titleFormat: "{source} -> {target}",
12
+ requiredReviewers: [],
13
+ optionalReviewers: [],
14
+ defaults: {
15
+ isDraft: false,
16
+ autoComplete: false,
17
+ deleteSourceBranch: true,
18
+ },
19
+ };
20
+
21
+ /**
22
+ * Encontra o arquivo de config no diretorio atual ou acima
23
+ */
24
+ export async function findConfigPath(startDir = process.cwd()) {
25
+ let dir = startDir;
26
+ const root =
27
+ process.platform === "win32" ? dir.split("\\")[0] + "\\" : "/";
28
+
29
+ while (true) {
30
+ const configPath = join(dir, CONFIG_FILE);
31
+ try {
32
+ await access(configPath);
33
+ return configPath;
34
+ } catch {
35
+ const parent =
36
+ process.platform === "win32"
37
+ ? join(dir, "..")
38
+ : join(dir, "..");
39
+ if (parent === dir || dir === root) break;
40
+ dir = parent;
41
+ }
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Carrega a configuracao
49
+ */
50
+ export async function loadConfig() {
51
+ const configPath = await findConfigPath();
52
+ if (!configPath) return null;
53
+
54
+ try {
55
+ const raw = await readFile(configPath, "utf-8");
56
+ const config = JSON.parse(raw);
57
+ return { ...DEFAULT_CONFIG, ...config, _path: configPath };
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Salva a configuracao
65
+ */
66
+ export async function saveConfig(config, dir = process.cwd()) {
67
+ const { _path, ...data } = config;
68
+ const configPath = _path || join(dir, CONFIG_FILE);
69
+ await writeFile(configPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
70
+ return configPath;
71
+ }
72
+
73
+ /**
74
+ * Retorna a config padrao
75
+ */
76
+ export function getDefaultConfig() {
77
+ return { ...DEFAULT_CONFIG };
78
+ }
package/src/git.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Git operations utility module
3
+ * Wraps child_process.execSync for common git commands
4
+ */
5
+
6
+ import { execSync } from "child_process";
7
+
8
+ /**
9
+ * Executa um comando git e retorna o output
10
+ * @param {string} args - Argumentos do git
11
+ * @returns {string} Output do comando
12
+ */
13
+ function exec(args) {
14
+ return execSync(`git ${args}`, { encoding: "utf-8" }).trim();
15
+ }
16
+
17
+ /**
18
+ * Verifica se estamos dentro de um repositorio git
19
+ */
20
+ export function isGitRepo() {
21
+ try {
22
+ exec("rev-parse --is-inside-work-tree");
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Retorna o nome da branch atual
31
+ */
32
+ export function getCurrentBranch() {
33
+ try {
34
+ return exec("rev-parse --abbrev-ref HEAD");
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Verifica se existem alteracoes nao commitadas (staged ou unstaged)
42
+ * @returns {{ hasUnstaged: boolean, hasStaged: boolean, untrackedFiles: string[], modifiedFiles: string[], stagedFiles: string[] }}
43
+ */
44
+ export function getWorkingTreeStatus() {
45
+ const status = exec("status --porcelain");
46
+ const lines = status ? status.split("\n").filter(Boolean) : [];
47
+
48
+ const untrackedFiles = [];
49
+ const modifiedFiles = [];
50
+ const stagedFiles = [];
51
+
52
+ for (const line of lines) {
53
+ const index = line[0];
54
+ const worktree = line[1];
55
+ const file = line.slice(3);
56
+
57
+ if (index === "?" && worktree === "?") {
58
+ untrackedFiles.push(file);
59
+ } else {
60
+ if (worktree === "M" || worktree === "D") {
61
+ modifiedFiles.push(file);
62
+ }
63
+ if (index === "M" || index === "A" || index === "D" || index === "R") {
64
+ stagedFiles.push(file);
65
+ }
66
+ }
67
+ }
68
+
69
+ return {
70
+ hasUnstaged: modifiedFiles.length > 0 || untrackedFiles.length > 0,
71
+ hasStaged: stagedFiles.length > 0,
72
+ untrackedFiles,
73
+ modifiedFiles,
74
+ stagedFiles,
75
+ clean: lines.length === 0,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Faz git add -A (stage all)
81
+ */
82
+ export function stageAll() {
83
+ exec("add -A");
84
+ }
85
+
86
+ /**
87
+ * Faz git commit com a mensagem fornecida
88
+ * @param {string} message
89
+ */
90
+ export function commit(message) {
91
+ // Escape double quotes in message for shell
92
+ const escaped = message.replace(/"/g, '\\"');
93
+ exec(`commit -m "${escaped}"`);
94
+ }
95
+
96
+ /**
97
+ * Verifica se o remote 'origin' existe
98
+ */
99
+ export function hasRemoteOrigin() {
100
+ try {
101
+ const remotes = exec("remote");
102
+ return remotes.split("\n").includes("origin");
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Verifica se a branch existe no remote
110
+ * @param {string} branch
111
+ */
112
+ export function branchExistsOnRemote(branch) {
113
+ try {
114
+ const result = exec(`ls-remote --heads origin refs/heads/${branch}`);
115
+ return result.length > 0;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Verifica se existem commits locais nao enviados ao remote
123
+ * @param {string} branch
124
+ * @returns {number} Numero de commits pendentes
125
+ */
126
+ export function getUnpushedCommitCount(branch) {
127
+ try {
128
+ const count = exec(`rev-list --count origin/${branch}..HEAD`);
129
+ return parseInt(count, 10) || 0;
130
+ } catch {
131
+ // Se origin/branch nao existe, todos os commits sao "unpushed"
132
+ return -1;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Lista os commits nao pushados (resumo)
138
+ * @param {string} branch
139
+ * @returns {string[]}
140
+ */
141
+ export function getUnpushedCommits(branch) {
142
+ try {
143
+ const log = exec(`log origin/${branch}..HEAD --oneline`);
144
+ return log ? log.split("\n").filter(Boolean) : [];
145
+ } catch {
146
+ return [];
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Publica a branch no remote (git push -u origin branch)
152
+ * @param {string} branch
153
+ */
154
+ export function publishBranch(branch) {
155
+ exec(`push -u origin ${branch}`);
156
+ }
157
+
158
+ /**
159
+ * Push simples (branch ja existe no remote)
160
+ */
161
+ export function push() {
162
+ exec("push");
163
+ }
164
+
165
+ /**
166
+ * Faz fetch do remote para garantir refs atualizadas
167
+ */
168
+ export function fetchOrigin() {
169
+ try {
170
+ exec("fetch origin --prune");
171
+ } catch {
172
+ // fetch pode falhar se nao tiver acesso, prosseguir
173
+ }
174
+ }