Skip to content

Commit f8caeee

Browse files
rajbosCopilot
andauthored
feat: add Copilot Cloud Agent sessions view (#835)
* feat: add Copilot cloud agent sessions view - New 'Cloud Agent' tab in Usage Analysis showing tasks, sessions, AI credits - agentSessionsService.ts: fetches /agents/repos/{owner}/{repo}/tasks API - Source detection: cloud-agent vs cli-remote vs unknown (no double-counting) - Detail fetch capped at 50 tasks/repo with partial flag for transparency - Lazy-loaded on first tab visit with progress updates - scripts/fetch-agent-sessions.js for workflow data pre-fetch - copilot-setup-steps.yml: daily cached agent session fetch via actions/cache - 11 new unit tests for detectSessionSource and fetchAgentSessionsForRepo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct permissions block indentation in copilot-setup-steps.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: sanitize agent sessions data at trust boundary to prevent XSS Add sanitizeAgentSessionsData() that escapes all string fields when receiving message.data from the extension host, matching the existing sanitizeRepoPrStatsData() pattern. renderAgentSessionsContent() then works with pre-sanitized strings and no longer calls escapeHtml() internally. Resolves CodeQL cross-site scripting finding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f243eb5 commit f8caeee

7 files changed

Lines changed: 1031 additions & 1 deletion

File tree

.github/workflows/copilot-setup-steps.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
# you'll need the `contents: read` permission. If you don't clone the repository in your setup steps,
2727
# Copilot will do this for you automatically after the steps complete.
2828
contents: read
29+
# Required to read and write the GitHub Actions cache for agent-sessions data
30+
actions: write
2931

3032
# You can define any steps you want, and they will run before the agent starts.
3133
# If you do not check out your code, Copilot will do this for you.
@@ -180,3 +182,32 @@ jobs:
180182
else
181183
echo "⚠️ No aggregated usage data file created"
182184
fi
185+
186+
# Fetch Copilot cloud-agent session statistics (cached daily to limit API calls)
187+
- name: Set agent sessions cache date key
188+
id: cache-date
189+
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
190+
191+
- name: Restore agent sessions cache
192+
id: restore-agent-sessions-cache
193+
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
194+
with:
195+
path: ./usage-data/agent-sessions.json
196+
key: agent-sessions-${{ github.repository }}-${{ steps.cache-date.outputs.date }}
197+
restore-keys: agent-sessions-${{ github.repository }}-
198+
199+
- name: Fetch Copilot agent sessions
200+
if: steps.restore-agent-sessions-cache.outputs.cache-hit != 'true'
201+
continue-on-error: true
202+
env:
203+
GITHUB_TOKEN: ${{ github.token }}
204+
run: |
205+
echo "🤖 Fetching Copilot cloud-agent session data (not cached yet for today)..."
206+
node scripts/fetch-agent-sessions.js
207+
208+
- name: Save agent sessions cache
209+
if: steps.restore-agent-sessions-cache.outputs.cache-hit != 'true' && hashFiles('./usage-data/agent-sessions.json') != ''
210+
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
211+
with:
212+
path: ./usage-data/agent-sessions.json
213+
key: agent-sessions-${{ github.repository }}-${{ steps.cache-date.outputs.date }}

scripts/fetch-agent-sessions.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Fetch Copilot cloud-agent session statistics for the current GitHub repository
4+
* and write aggregated results to ./usage-data/agent-sessions.json.
5+
*
6+
* Designed for use in GitHub Actions (copilot-setup-steps.yml).
7+
* Exits 0 even on error so the workflow step is non-fatal.
8+
*
9+
* Environment variables:
10+
* GITHUB_TOKEN — GitHub token with repo scope (set by Actions automatically)
11+
* GITHUB_REPOSITORY — "owner/repo" string (set by Actions automatically)
12+
*/
13+
14+
'use strict';
15+
16+
const https = require('https');
17+
const fs = require('fs');
18+
const path = require('path');
19+
20+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
21+
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || '';
22+
const OUTPUT_PATH = path.join(process.cwd(), 'usage-data', 'agent-sessions.json');
23+
const MAX_TASKS_DETAIL = 50;
24+
const CONCURRENCY = 5;
25+
26+
if (!GITHUB_TOKEN) {
27+
console.warn('⚠️ GITHUB_TOKEN not set — skipping agent session fetch');
28+
writeEmpty('GITHUB_TOKEN not set');
29+
process.exit(0);
30+
}
31+
32+
if (!GITHUB_REPOSITORY) {
33+
console.warn('⚠️ GITHUB_REPOSITORY not set — skipping agent session fetch');
34+
writeEmpty('GITHUB_REPOSITORY not set');
35+
process.exit(0);
36+
}
37+
38+
const [owner, repo] = GITHUB_REPOSITORY.split('/');
39+
if (!owner || !repo) {
40+
console.warn('⚠️ Invalid GITHUB_REPOSITORY format — skipping');
41+
writeEmpty('Invalid GITHUB_REPOSITORY');
42+
process.exit(0);
43+
}
44+
45+
/** @returns {Promise<{statusCode: number, body: string}>} */
46+
function githubGet(apiPath) {
47+
return new Promise((resolve, reject) => {
48+
const req = https.request(
49+
{
50+
hostname: 'api.github.com',
51+
path: apiPath,
52+
headers: {
53+
Authorization: `Bearer ${GITHUB_TOKEN}`,
54+
'User-Agent': 'copilot-token-tracker/fetch-agent-sessions',
55+
Accept: 'application/vnd.github.v3+json',
56+
'X-GitHub-Api-Version': '2022-11-28',
57+
},
58+
},
59+
(res) => {
60+
let body = '';
61+
res.on('data', (chunk) => (body += chunk));
62+
res.on('end', () => resolve({ statusCode: res.statusCode || 0, body }));
63+
},
64+
);
65+
req.on('error', reject);
66+
req.setTimeout(20000, () => req.destroy(new Error('Timed out')));
67+
req.end();
68+
});
69+
}
70+
71+
/** Detect whether a session came from the cloud agent or a CLI/remote session. */
72+
function detectSessionSource(session) {
73+
if (session.model !== undefined && session.model !== '') { return 'cloud-agent'; }
74+
if (Object.prototype.hasOwnProperty.call(session, 'usage') &&
75+
session.usage !== null && session.usage !== undefined) { return 'cloud-agent'; }
76+
if (session.model !== undefined) { return 'cli-remote'; }
77+
return 'unknown';
78+
}
79+
80+
async function fetchAllTasks(owner, repo, since) {
81+
const allTasks = [];
82+
const seen = new Set();
83+
84+
for (const archived of [false, true]) {
85+
for (let page = 1; page <= 10; page++) {
86+
let qs = `per_page=100&page=${page}`;
87+
if (archived) { qs += '&archived=true'; }
88+
if (since) { qs += `&since=${encodeURIComponent(since)}`; }
89+
90+
let res;
91+
try {
92+
res = await githubGet(`/agents/repos/${owner}/${repo}/tasks?${qs}`);
93+
} catch (e) {
94+
console.warn(`⚠️ Request error fetching tasks (page ${page}, archived=${archived}): ${e.message}`);
95+
break;
96+
}
97+
98+
if (res.statusCode === 404) {
99+
if (!archived) {
100+
console.warn(`⚠️ Copilot cloud agent not enabled or not accessible for ${owner}/${repo} (HTTP 404)`);
101+
}
102+
return null; // signal: repo not accessible
103+
}
104+
if (res.statusCode === 403) {
105+
console.warn(`⚠️ Access denied for ${owner}/${repo} (HTTP 403)`);
106+
return null;
107+
}
108+
if (res.statusCode < 200 || res.statusCode >= 300) {
109+
console.warn(`⚠️ HTTP ${res.statusCode} fetching tasks (page ${page})`);
110+
break;
111+
}
112+
113+
let tasks;
114+
try {
115+
const parsed = JSON.parse(res.body);
116+
tasks = Array.isArray(parsed?.tasks) ? parsed.tasks : (Array.isArray(parsed) ? parsed : []);
117+
} catch (e) {
118+
console.warn(`⚠️ Failed to parse tasks response: ${e.message}`);
119+
break;
120+
}
121+
122+
if (tasks.length === 0) { break; }
123+
for (const t of tasks) {
124+
if (!seen.has(t.id)) { seen.add(t.id); allTasks.push(t); }
125+
}
126+
if (tasks.length < 100) { break; }
127+
}
128+
}
129+
130+
return allTasks;
131+
}
132+
133+
async function fetchTaskDetail(owner, repo, taskId) {
134+
try {
135+
const res = await githubGet(`/agents/repos/${owner}/${repo}/tasks/${encodeURIComponent(taskId)}`);
136+
if (res.statusCode < 200 || res.statusCode >= 300) { return null; }
137+
const parsed = JSON.parse(res.body);
138+
return Array.isArray(parsed?.sessions) ? parsed.sessions : [];
139+
} catch (e) {
140+
return null;
141+
}
142+
}
143+
144+
function writeEmpty(reason) {
145+
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
146+
const result = {
147+
repos: [],
148+
totalTasks: 0,
149+
totalSessions: 0,
150+
totalCredits: 0,
151+
authenticated: !!GITHUB_TOKEN,
152+
since,
153+
fetchedAt: new Date().toISOString(),
154+
skippedReason: reason,
155+
};
156+
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
157+
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2), 'utf8');
158+
}
159+
160+
async function main() {
161+
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
162+
const sinceStr = since.toISOString();
163+
164+
console.log(`🤖 Fetching Copilot cloud-agent sessions for ${owner}/${repo} since ${sinceStr}...`);
165+
166+
const allTasks = await fetchAllTasks(owner, repo, sinceStr);
167+
if (allTasks === null) {
168+
// API not accessible — write empty result (non-fatal)
169+
writeEmpty('API not accessible');
170+
console.log('✅ Written empty agent sessions result');
171+
return;
172+
}
173+
174+
const tasksTotal = allTasks.length;
175+
const tasksToDetail = allTasks.slice(0, MAX_TASKS_DETAIL);
176+
const partial = tasksTotal > MAX_TASKS_DETAIL;
177+
178+
console.log(` Found ${tasksTotal} tasks, fetching details for ${tasksToDetail.length}${partial ? ' (capped)' : ''}...`);
179+
180+
let totalTasks = 0;
181+
let totalSessions = 0;
182+
let totalCredits = 0;
183+
184+
for (let i = 0; i < tasksToDetail.length; i += CONCURRENCY) {
185+
const batch = tasksToDetail.slice(i, i + CONCURRENCY);
186+
const results = await Promise.all(batch.map(t => fetchTaskDetail(owner, repo, t.id)));
187+
for (const sessions of results) {
188+
if (!sessions || sessions.length === 0) { continue; }
189+
const cloudSessions = sessions.filter(s => detectSessionSource(s) === 'cloud-agent');
190+
if (cloudSessions.length > 0) {
191+
totalTasks++;
192+
totalSessions += cloudSessions.length;
193+
for (const s of cloudSessions) {
194+
if (s.usage && typeof s.usage.credits === 'number') {
195+
totalCredits += s.usage.credits;
196+
}
197+
}
198+
}
199+
}
200+
}
201+
202+
const repoSummary = {
203+
owner,
204+
repo,
205+
totalTasks,
206+
totalSessions,
207+
totalCredits,
208+
tasksScanned: tasksToDetail.length,
209+
tasksTotal,
210+
partial,
211+
};
212+
213+
const result = {
214+
repos: [repoSummary],
215+
totalTasks,
216+
totalSessions,
217+
totalCredits,
218+
authenticated: true,
219+
since: sinceStr,
220+
fetchedAt: new Date().toISOString(),
221+
};
222+
223+
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
224+
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2), 'utf8');
225+
226+
console.log(`✅ Agent sessions: ${totalTasks} tasks, ${totalSessions} sessions, ${totalCredits.toFixed(1)} credits${partial ? ' (partial)' : ''}`);
227+
console.log(` Written to ${OUTPUT_PATH}`);
228+
}
229+
230+
main().catch((e) => {
231+
console.warn(`⚠️ Unexpected error in fetch-agent-sessions: ${e.message}`);
232+
writeEmpty(`Error: ${e.message}`);
233+
process.exit(0);
234+
});

0 commit comments

Comments
 (0)