Skip to content

Commit f3c2299

Browse files
rajbosCopilot
andcommitted
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>
1 parent f243eb5 commit f3c2299

7 files changed

Lines changed: 1001 additions & 6 deletions

File tree

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ jobs:
2121

2222
# Set the permissions to the lowest permissions possible needed for your steps.
2323
# Copilot will be given its own token for its operations.
24-
permissions:
25-
# If you want to clone the repository as part of your setup steps, for example to install dependencies,
26-
# you'll need the `contents: read` permission. If you don't clone the repository in your setup steps,
27-
# Copilot will do this for you automatically after the steps complete.
28-
contents: read
24+
permissions:
25+
# If you want to clone the repository as part of your setup steps, for example to install dependencies,
26+
# you'll need the `contents: read` permission. If you don't clone the repository in your setup steps,
27+
# Copilot will do this for you automatically after the steps complete.
28+
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)