azure-pipelines-tui 0.5.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 +114 -0
- package/dist/cache.js +54 -0
- package/dist/debugRetry.js +150 -0
- package/dist/debugSignalR.js +151 -0
- package/dist/debugWarnings.js +169 -0
- package/dist/lib/api.js +256 -0
- package/dist/lib/format.js +580 -0
- package/dist/lib/types.js +3 -0
- package/dist/screens/EnvironmentsScreen.js +220 -0
- package/dist/screens/MappingScreen.js +165 -0
- package/dist/screens/PipelineRunScreen.js +408 -0
- package/dist/screens/PipelineRunsScreen.js +135 -0
- package/dist/screens/PipelinesScreen.js +184 -0
- package/dist/screens/StagesScreen.js +194 -0
- package/dist/screens/context.js +2 -0
- package/dist/signalr.js +117 -0
- package/dist/tui.js +358 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Azure Pipelines TUI
|
|
2
|
+
|
|
3
|
+
A terminal UI for live-following Azure DevOps pipeline runs, with streaming logs via SignalR.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌ Pipeline ──────────────┐┌ Logs — Initialize job ───────────────────────────────┐
|
|
7
|
+
│ v + build ││ ##[section]Starting: Initialize job │
|
|
8
|
+
│ > + Initialize job ││ Agent name: 'myorg-pool-agent-abc123' │
|
|
9
|
+
│ > > Terraform plan ││ Agent machine name: 'myorg-pool-agent-abc123' │
|
|
10
|
+
│ . Terraform apply ││ Current agent version: '4.273.0' │
|
|
11
|
+
│ ~ 14 stages skipped ││ Agent running as: 'agentuser' │
|
|
12
|
+
└────────────────────────┘└──────────────────────────────────────────────────────┘
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 18+
|
|
18
|
+
- Azure CLI (`az`) signed in to the correct tenant
|
|
19
|
+
- `npm install`
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### TUI — `index.ts`
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Full build URL
|
|
27
|
+
npx tsx index.ts https://dev.azure.com/ORG/PROJECT/_build/results?buildId=1234
|
|
28
|
+
|
|
29
|
+
# URL + separate buildId
|
|
30
|
+
npx tsx index.ts https://dev.azure.com/ORG/PROJECT 1234
|
|
31
|
+
|
|
32
|
+
# org/project shorthand
|
|
33
|
+
npx tsx index.ts ORG/PROJECT 1234
|
|
34
|
+
|
|
35
|
+
# Separate arguments
|
|
36
|
+
npx tsx index.ts ORG PROJECT 1234
|
|
37
|
+
|
|
38
|
+
# Keep timestamps in log output
|
|
39
|
+
npx tsx index.ts ORG/PROJECT 1234 --keep-timestamps
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### Key bindings
|
|
43
|
+
|
|
44
|
+
| Key | Action |
|
|
45
|
+
|-----|--------|
|
|
46
|
+
| `↑` `↓` | Navigate tree / scroll logs |
|
|
47
|
+
| `Enter` `→` | Expand / select step |
|
|
48
|
+
| `←` `Esc` | Collapse / back to tree |
|
|
49
|
+
| `Tab` | Switch focus between tree and log panel |
|
|
50
|
+
| `f` `End` | Follow mode — tail the log |
|
|
51
|
+
| `r` | Retry/restart the selected stage |
|
|
52
|
+
| `q` `Ctrl+C` | Quit |
|
|
53
|
+
|
|
54
|
+
### Debug — `debugSignalR.ts`
|
|
55
|
+
|
|
56
|
+
Dumps all raw SignalR frames to stdout. Useful for exploring the protocol.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Full build URL
|
|
60
|
+
npx tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT/_build/results?buildId=1234
|
|
61
|
+
|
|
62
|
+
# URL + separate buildId
|
|
63
|
+
npx tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT 1234
|
|
64
|
+
|
|
65
|
+
# org/project shorthand
|
|
66
|
+
npx tsx debugSignalR.ts ORG/PROJECT 1234
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
The TUI combines two data sources:
|
|
72
|
+
|
|
73
|
+
1. **REST polling** (every 500 ms) — fetches build status, timeline records, and log lines via the Azure DevOps REST API.
|
|
74
|
+
2. **SignalR** (ASP.NET SignalR 1.x over WebSocket) — receives live events as log lines are written.
|
|
75
|
+
|
|
76
|
+
### SignalR connection
|
|
77
|
+
|
|
78
|
+
The connection requires three GUIDs, each from a different source:
|
|
79
|
+
|
|
80
|
+
| Name | Source | Used in |
|
|
81
|
+
|------|--------|---------|
|
|
82
|
+
| `instanceId` | `GET /_apis/connectionData` | negotiate and start URLs |
|
|
83
|
+
| `projectId` | `GET /_apis/projects/{name}` | WebSocket connect path |
|
|
84
|
+
| `contextToken` | negotiate response `.Url` field | connect query parameter |
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
negotiate → wss://...connect → /start → WatchBuild(projectId, buildId)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The WebSocket requires the bearer token in two places — as an `Authorization` header and as the `Sec-WebSocket-Protocol` subprotocol value, otherwise the server responds with a 302 redirect.
|
|
91
|
+
|
|
92
|
+
### Hub events
|
|
93
|
+
|
|
94
|
+
| Method | Action |
|
|
95
|
+
|--------|--------|
|
|
96
|
+
| `logConsoleLines` | Streams log lines directly into the log panel |
|
|
97
|
+
| `timelineRecordsUpdated` | Triggers a REST poll to refresh the tree |
|
|
98
|
+
| `buildUpdated` / `buildUpdated2` | Triggers a REST poll to refresh the header |
|
|
99
|
+
|
|
100
|
+
## Files
|
|
101
|
+
|
|
102
|
+
| File | Purpose |
|
|
103
|
+
|------|---------|
|
|
104
|
+
| `index.ts` | Blessed TUI: tree + log panel |
|
|
105
|
+
| `signalr.ts` | SignalR client (negotiate → connect → start → invoke) |
|
|
106
|
+
| `debugSignalR.ts` | Standalone debug script |
|
|
107
|
+
| `signalr-messages.jsonl` | Runtime dump of all SignalR frames (written by `signalr.ts`) |
|
|
108
|
+
| `signalr-negotiate.json` | Runtime dump of the negotiate response |
|
|
109
|
+
|
|
110
|
+
## Note
|
|
111
|
+
We reverse engineered this undocumented streaming API by downloading the following javascript bundles and feeding them to Claude:
|
|
112
|
+
|
|
113
|
+
* https://cdn.vsassets.io/ext/ms.vss-build-web/run/ms.vss-build-web.run.es6.Utcc_6.min.js
|
|
114
|
+
* https://cdn.vsassets.io/ext/ms.vss-features/signalr/ms.vss-features.signalr.es6.yu31LS.min.js
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// cache.ts — File-based TTL cache for Azure Pipelines TUI
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.readCache = readCache;
|
|
8
|
+
exports.writeCache = writeCache;
|
|
9
|
+
exports.clearAllCache = clearAllCache;
|
|
10
|
+
exports.clearByPrefix = clearByPrefix;
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const os_1 = __importDefault(require("os"));
|
|
14
|
+
const CACHE_DIR = path_1.default.join(os_1.default.homedir(), ".azure-pipelines-tui", "cache");
|
|
15
|
+
function sanitize(key) {
|
|
16
|
+
return key.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200);
|
|
17
|
+
}
|
|
18
|
+
function filePath(key) {
|
|
19
|
+
return path_1.default.join(CACHE_DIR, sanitize(key) + ".json");
|
|
20
|
+
}
|
|
21
|
+
function readCache(key) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs_1.default.readFileSync(filePath(key), "utf8");
|
|
24
|
+
const { data, cachedAt, ttl } = JSON.parse(raw);
|
|
25
|
+
if (Date.now() - cachedAt < ttl)
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function writeCache(key, data, ttlMs) {
|
|
32
|
+
fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
|
|
33
|
+
fs_1.default.writeFileSync(filePath(key), JSON.stringify({ data, cachedAt: Date.now(), ttl: ttlMs }), "utf8");
|
|
34
|
+
}
|
|
35
|
+
function clearAllCache() {
|
|
36
|
+
try {
|
|
37
|
+
fs_1.default.rmSync(CACHE_DIR, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
}
|
|
41
|
+
function clearByPrefix(prefix) {
|
|
42
|
+
const sp = sanitize(prefix);
|
|
43
|
+
try {
|
|
44
|
+
for (const f of fs_1.default.readdirSync(CACHE_DIR)) {
|
|
45
|
+
if (f.startsWith(sp)) {
|
|
46
|
+
try {
|
|
47
|
+
fs_1.default.unlinkSync(path_1.default.join(CACHE_DIR, f));
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* debugRetry.ts — standalone debug script for stage retry
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx tsx debugRetry.ts <org>/<project> <buildId> [stageRef]
|
|
8
|
+
* npx tsx debugRetry.ts IGH-Solution/IGH-Platform-Azure 12345
|
|
9
|
+
* npx tsx debugRetry.ts IGH-Solution/IGH-Platform-Azure 12345 MyStage
|
|
10
|
+
*
|
|
11
|
+
* Without stageRef: lists all stages with state/result.
|
|
12
|
+
* With stageRef: tries all state values (1 and 2) and forceRetryAllJobs combos.
|
|
13
|
+
*/
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
const https_1 = __importDefault(require("https"));
|
|
19
|
+
const child_process_1 = require("child_process");
|
|
20
|
+
const ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798";
|
|
21
|
+
const API_VER = "api-version=7.1";
|
|
22
|
+
const [orgProject, buildId, stageRef] = process.argv.slice(2);
|
|
23
|
+
if (!orgProject || !buildId) {
|
|
24
|
+
console.error("Usage: npx tsx debugRetry.ts <org>/<project> <buildId> [stageRef]");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const [org, ...rest] = orgProject.split("/");
|
|
28
|
+
const project = rest.join("/");
|
|
29
|
+
const ADO_BASE = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/build/builds/${buildId}`;
|
|
30
|
+
function getToken() {
|
|
31
|
+
const raw = (0, child_process_1.execSync)(`az account get-access-token --resource ${ADO_RESOURCE} --output json`, { encoding: "utf8", env: { ...process.env, AZURE_CONFIG_DIR: process.env["AZURE_CONFIG_DIR"] } });
|
|
32
|
+
return JSON.parse(raw).accessToken;
|
|
33
|
+
}
|
|
34
|
+
function httpGet(url, token) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const u = new URL(url);
|
|
37
|
+
https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
|
|
38
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, res => {
|
|
39
|
+
let data = "";
|
|
40
|
+
res.on("data", (c) => (data += c));
|
|
41
|
+
res.on("end", () => {
|
|
42
|
+
console.log(` GET ${url} → ${res.statusCode}`);
|
|
43
|
+
if ((res.statusCode ?? 0) >= 400)
|
|
44
|
+
return reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
45
|
+
resolve(JSON.parse(data));
|
|
46
|
+
});
|
|
47
|
+
}).on("error", reject);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function httpPatch(url, token, body) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const u = new URL(url);
|
|
53
|
+
const payload = JSON.stringify(body);
|
|
54
|
+
console.log(` PATCH ${url}`);
|
|
55
|
+
console.log(` Body: ${payload}`);
|
|
56
|
+
const req = https_1.default.request({
|
|
57
|
+
hostname: u.hostname,
|
|
58
|
+
path: u.pathname + u.search,
|
|
59
|
+
method: "PATCH",
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Bearer ${token}`,
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
64
|
+
},
|
|
65
|
+
}, res => {
|
|
66
|
+
let data = "";
|
|
67
|
+
res.on("data", (c) => (data += c));
|
|
68
|
+
res.on("end", () => {
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(data);
|
|
72
|
+
}
|
|
73
|
+
catch { /* raw */ }
|
|
74
|
+
resolve({ status: res.statusCode ?? 0, body: data, parsed });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
req.on("error", (e) => resolve({ status: 0, body: String(e) }));
|
|
78
|
+
req.write(payload);
|
|
79
|
+
req.end();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async function main() {
|
|
83
|
+
console.log(`\nOrg: ${org} Project: ${project} BuildId: ${buildId}`);
|
|
84
|
+
console.log("Fetching token…");
|
|
85
|
+
const token = getToken();
|
|
86
|
+
console.log("Token OK\n");
|
|
87
|
+
// Fetch timeline
|
|
88
|
+
const timeline = await httpGet(`${ADO_BASE}/timeline?${API_VER}`, token);
|
|
89
|
+
const stages = timeline.records
|
|
90
|
+
.filter(r => r.type === "Stage")
|
|
91
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
92
|
+
console.log("\n── Stages ────────────────────────────────────────────────────────");
|
|
93
|
+
for (const s of stages) {
|
|
94
|
+
const ref = s.identifier ?? s.name;
|
|
95
|
+
console.log(` [order=${s.order ?? "?"}] ${s.name.padEnd(40)} state=${s.state.padEnd(12)} result=${s.result ?? "(none)".padEnd(20)} ref="${ref}"`);
|
|
96
|
+
}
|
|
97
|
+
console.log("");
|
|
98
|
+
if (!stageRef) {
|
|
99
|
+
console.log("Pass a stageRef as third argument to test retry.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const target = stages.find(s => (s.identifier ?? s.name) === stageRef);
|
|
103
|
+
if (!target) {
|
|
104
|
+
console.warn(`Stage "${stageRef}" not found. Check spelling against the list above.`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
console.log(`\n── Testing retry on stage "${target.name}" (ref: ${stageRef}) ────`);
|
|
108
|
+
console.log(` Current state: ${target.state}, result: ${target.result ?? "(none)"}\n`);
|
|
109
|
+
const url = `${ADO_BASE}/stages/${encodeURIComponent(stageRef)}?${API_VER}`;
|
|
110
|
+
const combos = [
|
|
111
|
+
{ state: 1, forceRetryAllJobs: true, retryDependencies: true },
|
|
112
|
+
{ state: 1, forceRetryAllJobs: true, retryDependencies: false },
|
|
113
|
+
{ state: 1, forceRetryAllJobs: false, retryDependencies: true },
|
|
114
|
+
{ state: 1, forceRetryAllJobs: false, retryDependencies: false },
|
|
115
|
+
{ state: 2, forceRetryAllJobs: true, retryDependencies: true },
|
|
116
|
+
{ state: 2, forceRetryAllJobs: true, retryDependencies: false },
|
|
117
|
+
{ state: 2, forceRetryAllJobs: false, retryDependencies: true },
|
|
118
|
+
{ state: 2, forceRetryAllJobs: false, retryDependencies: false },
|
|
119
|
+
];
|
|
120
|
+
for (const combo of combos) {
|
|
121
|
+
console.log(`\n[state=${combo.state}, forceRetryAllJobs=${combo.forceRetryAllJobs}, retryDependencies=${combo.retryDependencies}]`);
|
|
122
|
+
const result = await httpPatch(url, token, combo);
|
|
123
|
+
console.log(` Response ${result.status}: ${result.body.slice(0, 300)}`);
|
|
124
|
+
if (result.status < 400) {
|
|
125
|
+
console.log(" ✓ SUCCESS — this combo works!");
|
|
126
|
+
await pollStageState(token, stageRef);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function pollStageState(token, ref, timeoutMs = 60_000, intervalMs = 2_000) {
|
|
132
|
+
console.log(`\n── Polling stage state (timeout ${timeoutMs / 1000}s) ────────────────`);
|
|
133
|
+
const deadline = Date.now() + timeoutMs;
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
const timeline = await httpGet(`${ADO_BASE}/timeline?${API_VER}`, token);
|
|
136
|
+
const stage = timeline.records.find(r => r.type === "Stage" && (r.identifier ?? r.name) === ref);
|
|
137
|
+
if (!stage) {
|
|
138
|
+
console.log(" Stage not found in timeline");
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
142
|
+
console.log(` [${ts}] state=${stage.state} result=${stage.result ?? "(none)"}`);
|
|
143
|
+
if (stage.state === "inProgress" || stage.state === "pending") {
|
|
144
|
+
console.log(" ✓ Stage transitioned — retry confirmed working.");
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
main().catch(e => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Debug script: connect to Azure DevOps SignalR and dump all messages
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT <buildId>
|
|
7
|
+
// tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT/_build/results?buildId=<id>
|
|
8
|
+
// tsx debugSignalR.ts https://dev.azure.com/ORG PROJECT <buildId>
|
|
9
|
+
// tsx debugSignalR.ts ORG/PROJECT <buildId>
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
const https_1 = __importDefault(require("https"));
|
|
15
|
+
const ws_1 = __importDefault(require("ws"));
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
const url_1 = require("url");
|
|
18
|
+
// ── Arg parsing ───────────────────────────────────────────────────────────────
|
|
19
|
+
function showUsage() {
|
|
20
|
+
console.error("Usage:\n" +
|
|
21
|
+
" tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT <buildId>\n" +
|
|
22
|
+
" tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT/_build/results?buildId=<id>\n" +
|
|
23
|
+
" tsx debugSignalR.ts https://dev.azure.com/ORG PROJECT <buildId>\n" +
|
|
24
|
+
" tsx debugSignalR.ts ORG/PROJECT <buildId>");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const positional = process.argv.slice(2).filter(a => !a.startsWith("--"));
|
|
28
|
+
let ORG, PROJECT, BUILD_ID;
|
|
29
|
+
const first = positional[0] ?? "";
|
|
30
|
+
if (first.startsWith("http")) {
|
|
31
|
+
const u = new url_1.URL(first);
|
|
32
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
33
|
+
ORG = parts[0] ?? "";
|
|
34
|
+
const bidParam = u.searchParams.get("buildId");
|
|
35
|
+
if (parts.length >= 2 && !parts[1].startsWith("_")) {
|
|
36
|
+
// https://dev.azure.com/ORG/PROJECT[/...][?buildId=N]
|
|
37
|
+
PROJECT = parts[1];
|
|
38
|
+
BUILD_ID = bidParam ? Number(bidParam) : Number(positional[1] ?? "0");
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// https://dev.azure.com/ORG PROJECT buildId
|
|
42
|
+
PROJECT = positional[1] ?? "";
|
|
43
|
+
BUILD_ID = Number(positional[2] ?? "0");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (first.includes("/")) {
|
|
47
|
+
// ORG/PROJECT buildId
|
|
48
|
+
const slash = first.indexOf("/");
|
|
49
|
+
ORG = first.slice(0, slash);
|
|
50
|
+
PROJECT = first.slice(slash + 1);
|
|
51
|
+
BUILD_ID = Number(positional[1] ?? "0");
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
showUsage();
|
|
55
|
+
}
|
|
56
|
+
if (!ORG || !PROJECT || !BUILD_ID)
|
|
57
|
+
showUsage();
|
|
58
|
+
const ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798";
|
|
59
|
+
function getToken() {
|
|
60
|
+
const raw = (0, child_process_1.execSync)(`az account get-access-token --resource ${ADO_RESOURCE} --output json`, { encoding: "utf8" });
|
|
61
|
+
return JSON.parse(raw).accessToken;
|
|
62
|
+
}
|
|
63
|
+
function getProjectId(org, project) {
|
|
64
|
+
const raw = (0, child_process_1.execSync)(`az devops project show --project "${project}" --organization https://dev.azure.com/${org} --query id -o tsv`, { encoding: "utf8" });
|
|
65
|
+
return raw.trim();
|
|
66
|
+
}
|
|
67
|
+
function httpGet(url, token) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const u = new url_1.URL(url);
|
|
70
|
+
const req = https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
|
|
71
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, res => {
|
|
72
|
+
// Follow redirects
|
|
73
|
+
if ((res.statusCode ?? 0) >= 300 && (res.statusCode ?? 0) < 400) {
|
|
74
|
+
const loc = res.headers["location"];
|
|
75
|
+
res.resume();
|
|
76
|
+
return resolve(httpGet(loc.startsWith("http") ? loc : `https://${u.hostname}${loc}`, token));
|
|
77
|
+
}
|
|
78
|
+
let data = "";
|
|
79
|
+
res.on("data", (c) => (data += c));
|
|
80
|
+
res.on("end", () => resolve(data));
|
|
81
|
+
});
|
|
82
|
+
req.on("error", reject);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async function main() {
|
|
86
|
+
const token = getToken();
|
|
87
|
+
const projectId = getProjectId(ORG, PROJECT);
|
|
88
|
+
console.log("projectId:", projectId);
|
|
89
|
+
const orgEncoded = encodeURIComponent(ORG);
|
|
90
|
+
// 1. Get instanceId (org-level, used for negotiate)
|
|
91
|
+
const connData = JSON.parse(await httpGet(`https://dev.azure.com/${orgEncoded}/_apis/connectionData?connectOptions=0&lastChangeId=-1&lastChangeId64=-1`, token));
|
|
92
|
+
console.log("instanceId (for negotiate):", connData.instanceId);
|
|
93
|
+
// 2. Negotiate with instanceId → returns contextToken
|
|
94
|
+
const CONNECTION_DATA = encodeURIComponent(JSON.stringify([
|
|
95
|
+
{ name: "builddetailhub" },
|
|
96
|
+
{ name: "taskagentpoolhub" },
|
|
97
|
+
]));
|
|
98
|
+
const negotiateUrl = `https://dev.azure.com/_signalr/${orgEncoded}/_apis/${connData.instanceId}/signalr/negotiate` +
|
|
99
|
+
`?transport=webSockets&clientProtocol=1.5&connectionData=${CONNECTION_DATA}&_=${Date.now()}`;
|
|
100
|
+
console.log("negotiate URL:", negotiateUrl);
|
|
101
|
+
const negotiated = JSON.parse(await httpGet(negotiateUrl, token));
|
|
102
|
+
console.log("negotiate response:", JSON.stringify(negotiated, null, 2));
|
|
103
|
+
// Extract contextToken from Url field
|
|
104
|
+
const contextToken = negotiated.Url?.match(/\/_apis\/([0-9a-f-]{36})\//i)?.[1];
|
|
105
|
+
console.log("contextToken:", contextToken);
|
|
106
|
+
// 3. Connect WebSocket — projectId in path, contextToken (from negotiate) as query param
|
|
107
|
+
const wsUrl = `wss://dev.azure.com/_signalr/${orgEncoded}/_apis/${projectId}/signalr/connect` +
|
|
108
|
+
`?transport=webSockets&clientProtocol=1.5` +
|
|
109
|
+
`&connectionToken=${encodeURIComponent(negotiated.ConnectionToken)}` +
|
|
110
|
+
`&connectionData=${CONNECTION_DATA}` +
|
|
111
|
+
(contextToken ? `&contextToken=${encodeURIComponent(contextToken)}` : "") +
|
|
112
|
+
`&tid=0`;
|
|
113
|
+
console.log("WebSocket URL:", wsUrl);
|
|
114
|
+
const ws = new ws_1.default(wsUrl, {
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${token}`,
|
|
117
|
+
"Sec-WebSocket-Protocol": `Bearer, ${token}`,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
ws.on("open", async () => {
|
|
121
|
+
console.log("WebSocket open");
|
|
122
|
+
// Start
|
|
123
|
+
try {
|
|
124
|
+
const startResp = await httpGet(`https://dev.azure.com/_signalr/${orgEncoded}/_apis/${connData.instanceId}/signalr/start` +
|
|
125
|
+
`?transport=webSockets&clientProtocol=1.5` +
|
|
126
|
+
`&connectionToken=${encodeURIComponent(negotiated.ConnectionToken)}` +
|
|
127
|
+
`&connectionData=${CONNECTION_DATA}&_=${Date.now()}`, token);
|
|
128
|
+
console.log("start response:", startResp);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
console.log("start error:", e.message);
|
|
132
|
+
}
|
|
133
|
+
// WatchBuild(projectId: Guid, buildId: Int32) — confirmed signature
|
|
134
|
+
const msg = JSON.stringify({ H: "builddetailhub", M: "WatchBuild", A: [projectId, BUILD_ID], I: "1" });
|
|
135
|
+
console.log("→ sending:", msg);
|
|
136
|
+
ws.send(msg);
|
|
137
|
+
});
|
|
138
|
+
ws.on("message", (raw) => {
|
|
139
|
+
const text = raw.toString();
|
|
140
|
+
if (text === "{}") {
|
|
141
|
+
process.stdout.write(".");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log("\n← received:", text);
|
|
145
|
+
});
|
|
146
|
+
ws.on("close", () => { console.log("\nWebSocket closed"); process.exit(0); });
|
|
147
|
+
ws.on("error", (e) => { console.log("WebSocket error:", e.message); });
|
|
148
|
+
// Run for 2 minutes
|
|
149
|
+
setTimeout(() => { console.log("\nTimeout — closing"); ws.close(); }, 120_000);
|
|
150
|
+
}
|
|
151
|
+
main().catch(e => { console.error("Fatal:", e.message); process.exit(1); });
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* debugWarnings.ts — report warning counts per stage/job/task for a build
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx tsx debugWarnings.ts <org>/<project> <buildId> [--logs]
|
|
8
|
+
*
|
|
9
|
+
* Without --logs: prints a tree of stages/jobs/tasks with warning counts.
|
|
10
|
+
* With --logs: also fetches log content and prints each ##[warning] line.
|
|
11
|
+
*/
|
|
12
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
const https_1 = __importDefault(require("https"));
|
|
17
|
+
const child_process_1 = require("child_process");
|
|
18
|
+
const ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798";
|
|
19
|
+
const API_VER = "api-version=7.1";
|
|
20
|
+
const rawArgs = process.argv.slice(2);
|
|
21
|
+
const [orgProject, buildId] = rawArgs.filter(a => !a.startsWith("--"));
|
|
22
|
+
const showLogs = rawArgs.includes("--logs");
|
|
23
|
+
if (!orgProject || !buildId) {
|
|
24
|
+
console.error("Usage: npx tsx debugWarnings.ts <org>/<project> <buildId> [--logs]");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const [org, ...rest] = orgProject.split("/");
|
|
28
|
+
const project = rest.join("/");
|
|
29
|
+
const enc = encodeURIComponent;
|
|
30
|
+
const ADO_BASE = `https://dev.azure.com/${enc(org)}/${enc(project)}/_apis/build/builds/${buildId}`;
|
|
31
|
+
function getToken() {
|
|
32
|
+
const raw = (0, child_process_1.execSync)(`az account get-access-token --resource ${ADO_RESOURCE} --output json`, { encoding: "utf8", env: { ...process.env, AZURE_CONFIG_DIR: process.env["AZURE_CONFIG_DIR"] } });
|
|
33
|
+
return JSON.parse(raw).accessToken;
|
|
34
|
+
}
|
|
35
|
+
function httpGet(url, token) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const u = new URL(url);
|
|
38
|
+
https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
|
|
39
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, res => {
|
|
40
|
+
let data = "";
|
|
41
|
+
res.on("data", (c) => (data += c));
|
|
42
|
+
res.on("end", () => {
|
|
43
|
+
if ((res.statusCode ?? 0) >= 400)
|
|
44
|
+
return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
|
|
45
|
+
resolve(JSON.parse(data));
|
|
46
|
+
});
|
|
47
|
+
}).on("error", reject);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function httpGetText(url, token) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const u = new URL(url);
|
|
53
|
+
https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
|
|
54
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/plain" } }, res => {
|
|
55
|
+
let data = "";
|
|
56
|
+
res.on("data", (c) => (data += c));
|
|
57
|
+
res.on("end", () => {
|
|
58
|
+
if ((res.statusCode ?? 0) >= 400)
|
|
59
|
+
return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
|
|
60
|
+
resolve(data);
|
|
61
|
+
});
|
|
62
|
+
}).on("error", reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function badge(warnings, errors) {
|
|
66
|
+
const parts = [];
|
|
67
|
+
if (warnings > 0)
|
|
68
|
+
parts.push(`⚠ ${warnings}w`);
|
|
69
|
+
if (errors > 0)
|
|
70
|
+
parts.push(`✗ ${errors}e`);
|
|
71
|
+
return parts.length ? ` [${parts.join(" ")}]` : "";
|
|
72
|
+
}
|
|
73
|
+
async function main() {
|
|
74
|
+
console.log(`\nOrg: ${org} Project: ${project} BuildId: ${buildId}`);
|
|
75
|
+
console.log("Fetching token…");
|
|
76
|
+
const token = getToken();
|
|
77
|
+
console.log("Token OK\n");
|
|
78
|
+
const timeline = await httpGet(`${ADO_BASE}/timeline?${API_VER}`, token);
|
|
79
|
+
const records = timeline.records ?? [];
|
|
80
|
+
// Build parent → children index
|
|
81
|
+
const children = new Map();
|
|
82
|
+
for (const r of records) {
|
|
83
|
+
const p = r.parentId ?? null;
|
|
84
|
+
if (!children.has(p))
|
|
85
|
+
children.set(p, []);
|
|
86
|
+
children.get(p).push(r);
|
|
87
|
+
}
|
|
88
|
+
// Stages = top-level records of type Stage
|
|
89
|
+
const stages = (children.get(null) ?? [])
|
|
90
|
+
.filter(r => r.type === "Stage")
|
|
91
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
92
|
+
// Totals from leaf records only (Tasks have no children) to avoid double-counting.
|
|
93
|
+
// Stage/Phase/Job warningCount is often 0 even when children have warnings.
|
|
94
|
+
const hasChildren = new Set(records.map(r => r.parentId).filter(Boolean));
|
|
95
|
+
let totalWarnings = 0;
|
|
96
|
+
let totalErrors = 0;
|
|
97
|
+
for (const r of records) {
|
|
98
|
+
if (!hasChildren.has(r.id)) {
|
|
99
|
+
totalWarnings += r.warningCount ?? 0;
|
|
100
|
+
totalErrors += r.errorCount ?? 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function sumDescendants(nodeId) {
|
|
104
|
+
const kids = children.get(nodeId) ?? [];
|
|
105
|
+
if (kids.length === 0)
|
|
106
|
+
return { warnings: 0, errors: 0 };
|
|
107
|
+
let w = 0, e = 0;
|
|
108
|
+
for (const kid of kids) {
|
|
109
|
+
if (!hasChildren.has(kid.id)) {
|
|
110
|
+
w += kid.warningCount ?? 0;
|
|
111
|
+
e += kid.errorCount ?? 0;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const s = sumDescendants(kid.id);
|
|
115
|
+
w += s.warnings;
|
|
116
|
+
e += s.errors;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { warnings: w, errors: e };
|
|
120
|
+
}
|
|
121
|
+
// Collect tasks with logs that have warnings (for --logs mode)
|
|
122
|
+
const warningTasks = [];
|
|
123
|
+
function printTree(nodes, indent) {
|
|
124
|
+
const sorted = [...nodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
125
|
+
for (const node of sorted) {
|
|
126
|
+
const w = node.warningCount ?? 0;
|
|
127
|
+
const e = node.errorCount ?? 0;
|
|
128
|
+
const b = badge(w, e);
|
|
129
|
+
console.log(`${indent}${node.type.padEnd(6)} ${node.name}${b}`);
|
|
130
|
+
if (showLogs && w > 0 && node.log?.url) {
|
|
131
|
+
warningTasks.push({ name: node.name, logUrl: node.log.url });
|
|
132
|
+
}
|
|
133
|
+
const kids = children.get(node.id) ?? [];
|
|
134
|
+
if (kids.length)
|
|
135
|
+
printTree(kids, indent + " ");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
console.log("── Warning/Error tree ────────────────────────────────────────────");
|
|
139
|
+
for (const stage of stages) {
|
|
140
|
+
const { warnings: sw, errors: se } = sumDescendants(stage.id);
|
|
141
|
+
console.log(`\nSTAGE ${stage.name} (state=${stage.state} result=${stage.result ?? "—"})${badge(sw, se)}`);
|
|
142
|
+
const kids = children.get(stage.id) ?? [];
|
|
143
|
+
printTree(kids, " ");
|
|
144
|
+
}
|
|
145
|
+
console.log(`\n── Totals ────────────────────────────────────────────────────────`);
|
|
146
|
+
console.log(` Warnings: ${totalWarnings}`);
|
|
147
|
+
console.log(` Errors : ${totalErrors}`);
|
|
148
|
+
if (!showLogs || warningTasks.length === 0)
|
|
149
|
+
return;
|
|
150
|
+
console.log(`\n── Warning log lines (${warningTasks.length} tasks) ──────────────────────────`);
|
|
151
|
+
for (const task of warningTasks) {
|
|
152
|
+
console.log(`\n [${task.name}]`);
|
|
153
|
+
try {
|
|
154
|
+
const text = await httpGetText(`${task.logUrl}?${API_VER}`, token);
|
|
155
|
+
const lines = text.split("\n").filter(l => /##\[warning\]/i.test(l));
|
|
156
|
+
if (lines.length === 0) {
|
|
157
|
+
console.log(" (no ##[warning] lines found in log)");
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
for (const line of lines)
|
|
161
|
+
console.log(` ${line.trim()}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
console.log(` Error fetching log: ${e.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
main().catch(e => { console.error(e); process.exit(1); });
|