diff --git a/README.md b/README.md
index 9043140..7ab3452 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,14 @@ By default, Violentmonkey will auto-update scripts from the original install loc
- Bypass owners reminder: For PRs into branches requiring a passing `ni/owners-approved` status, hovering over the Approve button pops up a reminder to consider bypassing owners
- Some tags/labels are colored (e.g. red if the label contains "Blocked")
+### Reviewer out-of-office annotations
+
+> Works out of the box for reviewers signed in with an NI/Emerson account. Users on other tenants/orgs can enable it by supplying their own Azure AD app registration (see [below](#maintainers-out-of-office-app-registration)).
+
+Reviewers on a PR are annotated with an **Out of Office** label when their Microsoft 365 presence shows they are out of office. Hovering the label shows their Outlook auto-reply message (if any).
+
+This is looked up live per-reviewer via the Microsoft Graph [presence API](https://learn.microsoft.com/en-us/graph/api/presence-get), so it is always current. For NI/Emerson accounts it works with no setup β the script signs you in silently using a shared, single-tenant Azure AD app registration the first time you view a PR. Reviewers that can't be resolved simply show no label.
+
### Overall
- Scrollbars throughout the site will now match the current Azure DevOps color theme
@@ -95,6 +103,15 @@ Trophies are awarded for notable PRs and shown in a trophies section on the Over
- [Contributing to this project](CONTRIBUTING.md)
- [GitHub repo](https://github.com/alejandro5042/azdo-userscripts)
+## Maintainers: out-of-office app registration
+
+The [reviewer out-of-office annotations](#reviewer-out-of-office-annotations) use a shared Azure AD app registration so end users don't have to create their own. A few notes for maintainers:
+
+- **The client ID is committed in the script on purpose.** This is an OAuth 2.0 Authorization Code + PKCE public client, which has no client secret. The client ID is not sensitive; it is safe to ship in this public repo. It is defined as `OOO_DEFAULT_GRAPH_CLIENT_ID` in [src/azdo-pr-dashboard.user.js](src/azdo-pr-dashboard.user.js).
+- **The app is single-tenant** (NI/Emerson) with delegated `Presence.Read.All`, admin-consented once by IT. Only tenant accounts can obtain a token, and only to read presence as the signed-in user. It also requests the standard `offline_access` scope so the script can silently refresh access tokens via a background request instead of a popup; because the redirect URIs are SPA-type, these refresh tokens last 24h, after which one silent re-auth popup is needed.
+- **Redirect URIs are the one thing to manage.** Azure AD SPA redirect URIs cannot be wildcarded, and `https://dev.azure.com/` server-redirects to the org root (stripping the OAuth params), so the redirect URI must be the org-specific root. Register each AZDO org root your users use as a **Single-page application** redirect URI on the app registration, e.g. `https://dev.azure.com/ni/`. To support a new org later, add its root URL there β no code change is needed.
+- **Users on another tenant/org** can point the script at their own app registration via the Tampermonkey menu command _"OOO Lookup: Configure Graph Client ID"_ without editing the code.
+
## Privacy
The update URL goes through a URL redirect service to get a rough idea of how many people are using this script. To opt-out, change the update URL to the original download URL your usescript dashboard (or disable updates). This redirect service may also help if the URL needs to change; e.g. if the file is moved or renamed.
diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js
index 0da5872..6591899 100644
--- a/src/azdo-pr-dashboard.user.js
+++ b/src/azdo-pr-dashboard.user.js
@@ -1,7 +1,7 @@
// ==UserScript==
// @name More Awesome Azure DevOps (userscript)
-// @version 3.12.1
+// @version 3.13.0
// @author Alejandro Barreto (NI)
// @description Makes general improvements to the Azure DevOps experience, particularly around pull requests. Also contains workflow improvements for NI engineers.
// @license MIT
@@ -37,17 +37,45 @@
// @grant GM_deleteValue
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
+// @grant GM_xmlhttpRequest
+// @connect graph.microsoft.com
+// @connect login.microsoftonline.com
// ==/UserScript==
(function () {
'use strict';
+ // Early check: are we in an OOO auth redirect popup from the PKCE OAuth flow?
+ // This runs before any other script logic and closes the popup after relaying the code.
+ // Uses GM_setValue (Tampermonkey IPC) β reliable across sandboxed script contexts.
+ {
+ const oooParams = new URLSearchParams(window.location.search);
+ const oooState = oooParams.get('state');
+ if (oooState && oooState.startsWith('azdo-ooo-')) {
+ try {
+ // Key is state-specific to avoid collisions between concurrent attempts.
+ GM_setValue(`oooAuthResult-${oooState}`, JSON.stringify({
+ code: oooParams.get('code') || null,
+ state: oooState,
+ error: oooParams.get('error_description') || null,
+ errorCode: oooParams.get('error') || null,
+ }));
+ } catch (e) { /* ignore */ }
+ setTimeout(() => window.close(), 200);
+ return;
+ }
+ }
+
// All REST API calls should fail after a timeout, instead of going on forever.
$.ajaxSetup({ timeout: 5000 });
let currentUser;
let azdoApiBaseUrl;
+ let oooAccessToken = null;
+ let oooAccessTokenExpiry = null;
+ let oooAuthPromise = null; // coalesces concurrent token acquisitions (refresh or popup) across callers
+ let oooSilentAuthAttempted = false; // limit the fallback popup to one attempt per page session
// Some features only apply at National Instruments.
const atNI = /^ni\./i.test(window.location.hostname) || /^\/ni\//i.test(window.location.pathname);
@@ -76,14 +104,64 @@
'agent-arbitration-status-off': 'Off',
});
- eus.showTipOnce('release-2026-06-26', 'New in the AzDO userscript', `
-
Highlights from the 2026-06-26 update!
+ eus.showTipOnce('release-2026-07-21', 'New in the AzDO userscript', `
+
Highlights from the 2026-07-21 update!
-
Fix pipeline status in the pipelines list view. #255
-
Better match pipeline status icons to existing UI. #255
+
OOO info is now looked up live per-reviewer via Microsoft Graph (no more stale data!)
+
For non-NI/Emerson users, use script menu β OOO Lookup: Configure Graph Client ID to set up
Comments, bugs, suggestions? File an issue on GitHub π§‘
`);
+
+ GM_registerMenuCommand('OOO Lookup: Configure Graph Client ID', async () => {
+ const current = GM_getValue('oooGraphClientId', '');
+ // eslint-disable-next-line no-alert
+ const clientId = prompt(
+ 'OOO lookup works out of the box using a shared app registration β you normally do not '
+ + 'need to set anything here.\n\n'
+ + 'Only override the Azure AD Application (Client) ID if you are on a non-NI/Emerson tenant/org '
+ + 'and want to use your own app registration (SPA redirect URI '
+ + `${getOooRedirectUri()}, delegated Presence.Read.All + offline_access).\n\n`
+ + 'Leave blank to use the shared default.',
+ current,
+ );
+ if (clientId === null) {
+ return;
+ }
+ GM_setValue('oooGraphClientId', clientId.trim());
+ oooAccessToken = null;
+ oooAccessTokenExpiry = null;
+ oooAuthPromise = null;
+ oooSilentAuthAttempted = false;
+ GM_deleteValue('oooAccessToken');
+ GM_deleteValue('oooAccessTokenExpiry');
+ // A new client ID means any stored refresh token is for a different app; drop it.
+ GM_deleteValue('oooRefreshToken');
+ GM_deleteValue('oooRefreshTokenExpiry');
+
+ // Sign in immediately so the user consents once and sees any admin-approval message;
+ // after this, silent auth keeps the token fresh automatically.
+ try {
+ await acquireOooTokenInteractive();
+ swal.fire({ icon: 'success', title: 'OOO Lookup Active', text: 'Reload a PR page to see live OOO annotations.' });
+ } catch (e) {
+ if (e.code && /consent_required/i.test(e.code)) {
+ const tenantId = GM_getValue('oooTenantId', 'organizations');
+ const adminConsentUrl = `https://login.microsoftonline.com/${tenantId}/v2.0/adminconsent`
+ + `?client_id=${encodeURIComponent(getOooClientId())}`
+ + `&redirect_uri=${encodeURIComponent(getOooRedirectUri())}`;
+ swal.fire({
+ icon: 'warning',
+ title: 'Admin approval required',
+ html: 'Your organization requires an IT admin to approve this app before users can sign in.
'
+ + 'Share this URL with your IT admin (one-time setup, unlocks OOO for everyone):
'
+ + ``,
+ });
+ } else {
+ swal.fire({ icon: 'error', title: 'Authentication failed', text: String(e.message) });
+ }
+ }
+ });
}
// Start modifying the page once the DOM is ready.
@@ -213,6 +291,356 @@
return value;
}
+ // ===== OOO Lookup via Microsoft Graph API =====
+ // Looks up each reviewer's out-of-office status live at page load time.
+ // Uses a shared, single-tenant Azure AD app registration (Presence.Read.All, delegated). The
+ // client ID below is public by design: this is an Authorization Code + PKCE public client, so
+ // there is NO client secret to leak. The app is locked down by tenant + delegated scope + the
+ // registered SPA redirect URIs (one per AZDO org root, e.g. https://dev.azure.com/ni/).
+ // Power users on other orgs/tenants can override the client ID via the Tampermonkey menu.
+
+ // offline_access is required for AAD to return a refresh token, which lets us mint new access
+ // tokens via a background POST (no popup) until the refresh token's own expiry.
+ const OOO_GRAPH_SCOPES = 'https://graph.microsoft.com/Presence.Read.All offline_access';
+ const OOO_STATE_PREFIX = 'azdo-ooo-';
+ // Shared app registration ("TM RD PR Metrics"). Safe to commit β public PKCE client, no secret.
+ const OOO_DEFAULT_GRAPH_CLIENT_ID = '968d0bcc-467a-4ec6-96be-3a1ab9e339e4';
+
+ function getOooClientId() {
+ // A per-user override (via the Tampermonkey menu) takes precedence over the shared default.
+ return GM_getValue('oooGraphClientId', '') || OOO_DEFAULT_GRAPH_CLIENT_ID;
+ }
+
+ function getOooRedirectUri() {
+ // https://dev.azure.com/ does a server-side redirect to https://dev.azure.com/{org}/,
+ // which strips the OAuth code/state query params before the userscript can intercept them.
+ // Use the org-specific root URL instead β it doesn't redirect further.
+ if (window.location.hostname === 'dev.azure.com') {
+ const org = window.location.pathname.split('/').filter(Boolean)[0];
+ if (org) return `${window.location.origin}/${org}/`;
+ }
+ return `${window.location.origin}/`;
+ }
+
+ function getOooRandom(len = 43) {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
+ const buf = new Uint8Array(len);
+ crypto.getRandomValues(buf);
+ return Array.from(buf, b => chars[b % chars.length]).join('');
+ }
+
+ async function getOooPkceChallenge(verifier) {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
+ return btoa(String.fromCharCode(...new Uint8Array(digest)))
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+ }
+
+ // Promisified GM_xmlhttpRequest β bypasses CORS for all OOO-related network calls.
+ function oooXhr(method, url, { headers = {}, body = null } = {}) {
+ return new Promise((resolve, reject) => {
+ GM_xmlhttpRequest({
+ method,
+ url,
+ headers,
+ data: body,
+ onload: resp => resolve(resp),
+ onerror: () => reject(new Error(`Network error calling ${url}`)),
+ ontimeout: () => reject(new Error(`Timeout calling ${url}`)),
+ });
+ });
+ }
+
+ // Auto-discovers the AAD tenant ID from the user's email domain via OIDC metadata.
+ // Single-tenant apps (the default after Oct 2018) reject the /common endpoint;
+ // they require a tenant-specific URL like /login.microsoftonline.com/{tenantId}/...
+ async function getOooTenantId() {
+ const cached = GM_getValue('oooTenantId', '');
+ if (cached) return cached;
+
+ const email = currentUser && currentUser.uniqueName;
+ const domain = email && email.split('@')[1];
+ if (!domain) return 'organizations';
+
+ try {
+ const resp = await oooXhr(
+ 'GET',
+ `https://login.microsoftonline.com/${encodeURIComponent(domain)}/.well-known/openid-configuration`,
+ );
+ if (resp.status >= 200 && resp.status < 300) {
+ const cfg = JSON.parse(resp.responseText);
+ // issuer is "https://login.microsoftonline.com/{tenantId}/v2.0"
+ const m = cfg.issuer && cfg.issuer.match(/\/([a-f0-9-]{36})\//i);
+ if (m) {
+ GM_setValue('oooTenantId', m[1]);
+ return m[1];
+ }
+ }
+ } catch (e) { /* ignore */ }
+
+ return 'organizations';
+ }
+
+ async function acquireOooTokenInteractive(silent = false) {
+ const clientId = getOooClientId();
+ if (!clientId) throw new Error('OOO Graph Client ID not configured. Use the Tampermonkey menu.');
+
+ const tenantId = await getOooTenantId();
+ const verifier = getOooRandom();
+ const challenge = await getOooPkceChallenge(verifier);
+ const state = OOO_STATE_PREFIX + getOooRandom(16);
+ const redirectUri = getOooRedirectUri();
+
+ sessionStorage.setItem(`ooo-pkce-${state}`, verifier);
+
+ function openAuthPopup(promptMode) {
+ const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`);
+ authUrl.searchParams.set('client_id', clientId);
+ authUrl.searchParams.set('response_type', 'code');
+ authUrl.searchParams.set('redirect_uri', redirectUri);
+ authUrl.searchParams.set('scope', OOO_GRAPH_SCOPES);
+ authUrl.searchParams.set('state', state);
+ authUrl.searchParams.set('code_challenge', challenge);
+ authUrl.searchParams.set('code_challenge_method', 'S256');
+ authUrl.searchParams.set('login_hint', currentUser.uniqueName);
+ authUrl.searchParams.set('prompt', promptMode);
+
+ return new Promise((resolve, reject) => {
+ // Clear any stale result from a prior attempt with this same state.
+ GM_deleteValue(`oooAuthResult-${state}`);
+
+ const popup = window.open(authUrl.toString(), 'ooo-auth', 'width=600,height=600,resizable=yes');
+ if (!popup) { reject(new Error('Popup blocked - allow popups for this site')); return; }
+
+ debug('OOO auth popup opened. Waiting for redirect back to', redirectUri);
+
+ function handleResult(result) {
+ clearInterval(pollId); // eslint-disable-line no-use-before-define
+ clearTimeout(timer); // eslint-disable-line no-use-before-define
+ GM_deleteValue(`oooAuthResult-${state}`);
+ debug('OOO auth result received:', result.code ? 'code obtained' : `error: ${result.errorCode}`);
+ if (result.error || result.errorCode) {
+ const err = new Error(result.error || result.errorCode);
+ err.code = result.errorCode;
+ reject(err);
+ return;
+ }
+ resolve(result.code);
+ }
+
+ // Poll GM storage every 200ms. GM_addValueChangeListener's 'remote' flag is unreliable
+ // for popupβopener communication (may be false even across windows), so polling is safer.
+ const pollId = setInterval(() => {
+ const rawVal = GM_getValue(`oooAuthResult-${state}`, null);
+ if (rawVal === null) return;
+ try { handleResult(JSON.parse(rawVal)); } catch (e) { clearInterval(pollId); }
+ }, 200);
+
+ const timer = setTimeout(() => {
+ clearInterval(pollId);
+ GM_deleteValue(`oooAuthResult-${state}`);
+ reject(new Error('Authentication timed out (2 min). Check that the redirect URI matches your Azure AD registration.'));
+ }, 2 * 60 * 1000);
+ });
+ }
+
+ // Try silent first (prompt=none); fall back to interactive if AAD requires user interaction.
+ debug('Starting OOO auth. Tenant:', tenantId, 'Redirect URI:', redirectUri);
+ let code;
+ try {
+ code = await openAuthPopup('none');
+ } catch (e) {
+ debug('Silent OOO auth failed, errorCode:', e.code, 'β trying interactive');
+ if (e.code && /interaction_required|consent_required|login_required/i.test(e.code)) {
+ if (silent) throw e; // don't fall back to interactive UI in silent mode
+ code = await openAuthPopup('select_account');
+ } else {
+ throw e;
+ }
+ }
+
+ const storedVerifier = sessionStorage.getItem(`ooo-pkce-${state}`) || verifier;
+ sessionStorage.removeItem(`ooo-pkce-${state}`);
+
+ const tokenResp = await oooXhr('POST', `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
+ // SPA app registrations require an Origin header matching the redirect URI origin.
+ // GM_xmlhttpRequest doesn't send one automatically, so we add it explicitly.
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: window.location.origin },
+ body: new URLSearchParams([
+ ['grant_type', 'authorization_code'],
+ ['client_id', clientId],
+ ['code', code],
+ ['redirect_uri', redirectUri],
+ ['code_verifier', storedVerifier],
+ ['scope', OOO_GRAPH_SCOPES],
+ ]).toString(),
+ });
+
+ if (tokenResp.status < 200 || tokenResp.status >= 300) {
+ const errData = JSON.parse(tokenResp.responseText || '{}');
+ error('OOO token exchange failed. Status:', tokenResp.status, 'Body:', tokenResp.responseText);
+ throw new Error(errData.error_description || errData.error || `Token exchange failed: HTTP ${tokenResp.status}`);
+ }
+
+ debug('OOO token exchange succeeded. Access token obtained.');
+ storeOooTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ true);
+ return oooAccessToken;
+ }
+
+ // Persists tokens returned by either the authorization_code or refresh_token grant. Refresh
+ // tokens are single-use and rotated, so we always store the latest one. For SPA redirect URIs
+ // the refresh token expires 24h after the original interactive login and that expiry carries
+ // over across rotations, so we only stamp the 24h clock on a fresh login.
+ function storeOooTokens(tokenData, isFreshLogin) {
+ oooAccessToken = tokenData.access_token;
+ oooAccessTokenExpiry = new Date(Date.now() + (tokenData.expires_in - 300) * 1000);
+ // Cache in GM storage so the token survives page navigations.
+ GM_setValue('oooAccessToken', oooAccessToken);
+ GM_setValue('oooAccessTokenExpiry', oooAccessTokenExpiry.getTime());
+
+ if (tokenData.refresh_token) {
+ GM_setValue('oooRefreshToken', tokenData.refresh_token);
+ if (isFreshLogin) {
+ GM_setValue('oooRefreshTokenExpiry', Date.now() + 24 * 60 * 60 * 1000);
+ }
+ }
+ }
+
+ // Mints a new access token from the stored refresh token via a background POST β no popup.
+ // Returns null (and drops the refresh token) if there's none or AAD rejects it, so the caller
+ // falls back to an interactive/silent popup.
+ async function refreshOooAccessToken() {
+ const refreshToken = GM_getValue('oooRefreshToken', '');
+ const refreshExpiry = GM_getValue('oooRefreshTokenExpiry', 0);
+ // SPA refresh tokens live only 24h; once past that a popup is unavoidable, so don't bother.
+ if (!refreshToken || Date.now() >= refreshExpiry) return null;
+
+ const clientId = getOooClientId();
+ if (!clientId) return null;
+ const tenantId = await getOooTenantId();
+
+ const tokenResp = await oooXhr('POST', `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: window.location.origin },
+ body: new URLSearchParams([
+ ['grant_type', 'refresh_token'],
+ ['client_id', clientId],
+ ['refresh_token', refreshToken],
+ ['scope', OOO_GRAPH_SCOPES],
+ ]).toString(),
+ });
+
+ if (tokenResp.status < 200 || tokenResp.status >= 300) {
+ // Expired or revoked β drop it so the next acquisition falls back to a popup.
+ GM_deleteValue('oooRefreshToken');
+ GM_deleteValue('oooRefreshTokenExpiry');
+ return null;
+ }
+
+ storeOooTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ false);
+ return oooAccessToken;
+ }
+
+ function getOooGraphAccessToken() {
+ // In-memory cache (fastest path, valid for current page session).
+ if (oooAccessToken && oooAccessTokenExpiry && new Date() < oooAccessTokenExpiry) {
+ return oooAccessToken;
+ }
+ // GM_setValue cache β survives SPA page navigation, avoids a popup on every page load.
+ const savedToken = GM_getValue('oooAccessToken', '');
+ const savedExpiry = GM_getValue('oooAccessTokenExpiry', 0);
+ if (savedToken && Date.now() < savedExpiry) {
+ oooAccessToken = savedToken;
+ oooAccessTokenExpiry = new Date(savedExpiry);
+ return oooAccessToken;
+ }
+ // No valid cached access token. Acquire one, coalescing all concurrent callers (e.g. every
+ // reviewer on the page) onto a single attempt so we never fire duplicate single-use refresh
+ // requests or open multiple popups. Try the refresh token first (a background POST, no popup);
+ // only if that fails fall back to a silent popup, at most once per page session.
+ if (!oooAuthPromise && getOooClientId()) {
+ oooAuthPromise = (async () => {
+ const refreshed = await refreshOooAccessToken();
+ if (refreshed) return refreshed;
+
+ if (oooSilentAuthAttempted) return null;
+ oooSilentAuthAttempted = true;
+ return acquireOooTokenInteractive(/* silent= */ true).catch(() => null);
+ })().finally(() => { oooAuthPromise = null; });
+ }
+ return oooAuthPromise;
+ }
+
+ // Resolves an email to the user's AAD object ID (originId), which Graph's /presence endpoint
+ // requires. Neither the reviewer's identity.id nor its "aad." descriptor is the AAD object ID
+ // (they're AZDO-internal IMS/storage GUIDs), and the object ID isn't present anywhere on the page.
+ // AZDO's Identity Picker is served same-origin, so it authenticates via the existing session with
+ // no extra Microsoft Graph permission or admin consent.
+ async function resolveOooAadObjectId(email) {
+ const response = await fetch(`${azdoApiBaseUrl}_apis/IdentityPicker/Identities?api-version=5.0-preview.1`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({
+ query: email,
+ identityTypes: ['user'],
+ operationScopes: ['ims', 'source'],
+ properties: ['DisplayName', 'Mail'],
+ options: { MinResults: 1, MaxResults: 20 },
+ }),
+ });
+ if (!response.ok) return null;
+ const data = await response.json();
+ const identities = (data.results && data.results[0] && data.results[0].identities) || [];
+ const wanted = email.toLowerCase();
+ const match = identities.find(i => (i.mail || i.signInAddress || '').toLowerCase() === wanted);
+ return (match && match.originId) || null;
+ }
+
+ // Graph's /presence endpoint requires the user's AAD object ID (GUID), resolved from their email.
+ async function fetchOooForEmail(email) {
+ const cacheKey = `azdo-userscripts-ooo-${email.replace(/[^a-z0-9]/gi, '_')}`;
+ const cacheDurationMs = 2 * 60 * 60 * 1000; // 2 hours
+
+ try {
+ const cached = JSON.parse(localStorage.getItem(cacheKey));
+ if (cached && Date.now() < cached.expiry) return cached.data;
+ } catch (e) { /* ignore */ }
+
+ const token = await getOooGraphAccessToken();
+ if (!token) return null;
+
+ const userGuid = await resolveOooAadObjectId(email);
+ if (!userGuid) return null;
+
+ try {
+ const graphResp = await oooXhr(
+ 'GET',
+ `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userGuid)}/presence`,
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } },
+ );
+
+ let oooData = null;
+
+ if (graphResp.status >= 200 && graphResp.status < 300) {
+ const presence = JSON.parse(graphResp.responseText);
+ const oof = presence.outOfOfficeSettings;
+ if (oof && oof.isOutOfOffice) {
+ oooData = { Text: oof.message || '' };
+ }
+ localStorage.setItem(cacheKey, JSON.stringify({ expiry: Date.now() + cacheDurationMs, data: oooData }));
+ } else if (graphResp.status === 401) {
+ // Token rejected; clear both in-memory and GM storage so the next call re-authenticates.
+ oooAccessToken = null;
+ oooAccessTokenExpiry = null;
+ GM_deleteValue('oooAccessToken');
+ GM_deleteValue('oooAccessTokenExpiry');
+ }
+
+ return oooData;
+ } catch (e) {
+ error(`OOO lookup failed for ${email}:`, e);
+ return null;
+ }
+ }
+
function watchForPipelinesPage(session, pageData) {
addStyleOnce('agent-css', /* css */ `
.pipeline-status-icon {
@@ -662,26 +1090,6 @@
const dataMaxAgeInDays = 3;
- let oooInfo;
- let oooByEmail;
- try {
- oooInfo = await fetchJsonAndCache('outOfOfficeLatest', 12 * 60 * 60, `${azdoApiBaseUrl}/_apis/git/repositories/3378df6b-8fc9-41dd-a9d9-16640f2392cb/items?api-version=6.0&path=/data/outOfOfficeLatest.json&version=main`);
- if (oooInfo.version === 1) {
- const dataDate = dateFns.parse(oooInfo.date);
- if (dateFns.differenceInDays(new Date(), dataDate) >= dataMaxAgeInDays) {
- // This data is too old. It hasn't been updated properly by the pipeline producing it. Avoid annotating.
- throw new Error(`Data is too old (must be ${dataMaxAgeInDays} days old or less). Data date is: ${dataDate.toISOString()}`);
- }
-
- oooByEmail = _.keyBy(oooInfo.value, 'Email');
- } else {
- throw new Error(`Invalid version: ${oooInfo.version}`);
- }
- } catch (e) {
- oooInfo = null;
- error(`Cannot annotate out-of-office info on PRs: ${e}`);
- }
-
let employeeInfo;
let employeeByEmail;
let me;
@@ -787,32 +1195,22 @@
}
}
- if (oooInfo) {
- const ooo = oooByEmail[email];
- if (ooo && dateFns.isFuture(ooo.End)) {
- let label;
+ // OOO lookup via Microsoft Graph API β fires asynchronously so it doesn't block other annotations.
+ // The reviewer's AAD object ID is resolved from their email inside fetchOooForEmail.
+ fetchOooForEmail(email).then(ooo => {
+ if (!ooo) return;
- if (dateFns.isFuture(ooo.Start)) {
- if (dateFns.differenceInHours(ooo.Start, new Date()) <= 24) {
- label = 'Unavailable in <24h';
- } else {
- // Don't show a label. This person is leaving too far into the future.
- label = null;
- }
- } else {
- label = `Returns in ${dateFns.distanceInWordsToNow(ooo.End)}`;
- }
+ // Strip HTML from the OOO message for safe display. DOMParser does not
+ // execute scripts or fire resource-loading events (unlike innerHTML on a live node).
+ const oooText = new DOMParser().parseFromString(ooo.Text, 'text/html').body.textContent || '';
- if (label) {
- const tooltipHtml = `
-