Naming usage ui copy (#8357)

* fix: work-item naming, usage % rounding, and terminal/delete copy

Address prod-release-scan P2s:

- #8238: recognize Bitbucket Server (/projects|users/.../repos/.../pull-requests/N)
  and Azure DevOps (/_git/REPO/pullrequest/N) PR URL shapes in
  work-item-reference, alongside Bitbucket Cloud; graceful fallback preserved.

- #7574: getDisplayedUsagePercentage now rounds the used value before taking the
  `remaining` complement, so the compact status bar (raw usedPercent) and tooltip
  (pre-rounded clampUsedPercent) can no longer disagree by 1% at a .5 fraction.
  clampUsedPercent moves to the shared module as the single rounding source.

- #7459: mixed remote+local batch delete confirm no longer claims the whole
  batch is a permanent "remote host" delete — it now states remote items are
  permanent while local items move to the Trash/Recycle Bin.

- #8322: right-click-to-paste settings copy is platform-aware — "Control-click"
  on macOS, "Ctrl+right-click" on Windows/Linux — matching the ctrlKey gate.

Localization catalog synced (also picks up pre-existing UsagePercentageDisplayChangeNotice drift).

* Fix NaN% usage bar and label for non-finite provider values

Non-finite usedPercent inputs (NaN/Infinity) propagated through Math.round/min/max into the CSS bar width (`NaN%`) and displayed copy. clampUsedPercent now short-circuits to 0 in that case, with a test covering the divergence from getDisplayedUsagePercentage for the 'remaining' case.
This commit is contained in:
Jinjing 2026-07-11 22:37:26 -07:00 committed by GitHub
parent 9de1fb8d16
commit c99095b1e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 233 additions and 41 deletions

View File

@ -48,6 +48,12 @@ function needsRemoteDeleteConfirmation(node: TreeNode): boolean {
return operationOwner.kind !== 'local' && getFileExplorerOperationRoute(operationOwner) !== null
}
// Why: local deletes go to the OS Trash/Recycle Bin and stay recoverable, so a
// mixed batch must not describe every item as a permanent remote delete.
function isLocalDeleteNode(node: TreeNode): boolean {
return (node.operationOwner ?? { kind: 'unresolved' as const }).kind === 'local'
}
export function useFileDeletion({
activeWorktreeId,
openFiles,
@ -259,6 +265,11 @@ export function useFileDeletion({
return
}
const roots = selectDeletionRoots(nodes)
// Why: the batch confirms whenever any root is a permanent remote delete,
// but the selection can also include local roots that only go to the
// Trash — so a mixed batch needs copy that keeps that distinction.
const hasLocalDelete = roots.some(isLocalDeleteNode)
const trashName = isWindows ? 'Recycle Bin' : 'Trash'
// Why: selection is cleared once after the entire batch settles rather
// than per-node, so no concurrent completion can restore a partial
// stale set.
@ -273,15 +284,27 @@ export function useFileDeletion({
// Why: count the full selection, not the filtered roots —
// deleting a folder still deletes the selected children inside
// it, and the prompt should match what the user sees selected.
title: translate(
'auto.components.right.sidebar.useFileDeletion.af1270b90d',
'Permanently delete {{count}} items?',
{ count: nodes.length }
),
description: translate(
'auto.components.right.sidebar.useFileDeletion.dd029aa5cd',
'This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone.'
),
title: hasLocalDelete
? translate(
'auto.components.right.sidebar.useFileDeletion.77fdc36183',
'Delete {{count}} items?',
{ count: nodes.length }
)
: translate(
'auto.components.right.sidebar.useFileDeletion.af1270b90d',
'Permanently delete {{count}} items?',
{ count: nodes.length }
),
description: hasLocalDelete
? translate(
'auto.components.right.sidebar.useFileDeletion.fca915a67a',
'Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}.',
{ value0: trashName }
)
: translate(
'auto.components.right.sidebar.useFileDeletion.dd029aa5cd',
'This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone.'
),
confirmLabel: translate(
'auto.components.right.sidebar.useFileDeletion.92276aceb7',
'Delete'
@ -305,7 +328,7 @@ export function useFileDeletion({
)
})()
},
[confirm, runDelete, requestDelete, setSelectedPaths]
[confirm, isWindows, runDelete, requestDelete, setSelectedPaths]
)
return useMemo(

View File

@ -8,6 +8,7 @@ import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { getTerminalRightClickToPasteSearchEntry } from './terminal-windows-search'
import { OSC52_CLIPBOARD_SETTING_ID } from '../terminal-pane/osc52-clipboard-setting-anchor'
import { isMacPlatform } from '../terminal-pane/terminal-link-open-hints'
import { translate } from '@/i18n/i18n'
import {
DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY,
@ -90,6 +91,28 @@ export function TerminalInteractionSection({
updateSettings,
searchQuery
}: TerminalInteractionSectionProps): React.JSX.Element {
// Why: the context-menu escape hatch is gated on the Control key on every
// platform (see use-terminal-pane-context-menu), so macOS wording is
// "Control-click" while Windows/Linux keep "Ctrl+right-click".
const isMac = isMacPlatform()
const rightClickPasteDescription = isMac
? translate(
'auto.components.settings.TerminalInteractionSection.567633ff50',
'Right-click pastes the clipboard into the terminal. Control-click to open the context menu.'
)
: translate(
'auto.components.settings.TerminalPane.af0c3b6e39',
'Right-click pastes the clipboard into the terminal. Use Ctrl+right-click to open the context menu.'
)
const rightClickPasteSwitchDescription = isMac
? translate(
'auto.components.settings.TerminalInteractionSection.c64497148a',
'Right-click pastes the clipboard. Control-click opens the context menu.'
)
: translate(
'auto.components.settings.TerminalPane.16753eea48',
'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.'
)
return (
<section key="pane-interaction" className="space-y-3">
<SettingsSubsectionHeader
@ -227,10 +250,7 @@ export function TerminalInteractionSection({
'auto.components.settings.TerminalPane.9c178cf8aa',
'Right-click to paste'
)}
description={translate(
'auto.components.settings.TerminalPane.af0c3b6e39',
'Right-click pastes the clipboard into the terminal. Use Ctrl+right-click to open the context menu.'
)}
description={rightClickPasteDescription}
keywords={['terminal', 'right click', 'paste', 'context menu']}
>
<SettingsSwitchRow
@ -238,10 +258,7 @@ export function TerminalInteractionSection({
'auto.components.settings.TerminalPane.9c178cf8aa',
'Right-click to paste'
)}
description={translate(
'auto.components.settings.TerminalPane.16753eea48',
'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.'
)}
description={rightClickPasteSwitchDescription}
checked={settings.terminalRightClickToPaste}
onChange={() =>
updateSettings({

View File

@ -7,9 +7,15 @@ import {
getProviderUsageErrorMessage,
getProviderUsageStatusLabel
} from './usage-error-copy'
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import {
clampUsedPercent,
type UsagePercentageDisplay
} from '../../../../shared/usage-percentage-display'
import { formatUsagePercentageLabel } from './usage-percentage-label'
// Re-exported from its shared home so status-bar callers keep a single import.
export { clampUsedPercent }
export {
getProviderDisplayName,
getProviderUsageErrorMessage,
@ -193,11 +199,6 @@ export function getWindowSections(
// `text-background` for primary text and `text-background/50` for secondary
// to stay readable inside the inverted tooltip container.
// Why: single clamp for bar width + label so status bar and tooltip never diverge.
export function clampUsedPercent(usedPercent: number): number {
return Math.max(0, Math.min(100, Math.round(usedPercent)))
}
// Why: color-coded by consumption so users can quickly gauge urgency.
// Matches common harness usage meters (Claude/Codex): bars fill with % used.
// Green = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%).

View File

@ -3215,6 +3215,13 @@
"usagePercentageLabel": {
"used": "{{value0}}% used",
"remaining": "{{value0}}% left"
},
"UsagePercentageDisplayChangeNotice": {
"title": "Usage now shows % used",
"body": "Prefer remaining? Change it in Settings.",
"dismiss": "Dismiss",
"openSettings": "Open Settings",
"gotIt": "Got it"
}
}
},
@ -9010,6 +9017,10 @@
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "Used",
"usagePercentageDisplayRemaining": "Remaining"
},
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
}
},
"right": {
@ -9879,7 +9890,9 @@
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.",
"8b8ee9d22f": "Couldn't determine which host owns this file. Check the workspace connection and try again.",
"af1270b90d": "Permanently delete {{count}} items?",
"dd029aa5cd": "This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone."
"dd029aa5cd": "This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone.",
"77fdc36183": "Delete {{count}} items?",
"fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}."
},
"useFileExplorerHandlers": {
"32cd9fd991": "Cannot open symlink target"

View File

@ -1387,7 +1387,9 @@
"1ff5d979df": "Nadie asignado",
"checkActionRequiredHint": "Esta verificación necesita una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que se desbloquee la fusión.",
"dd5d9a4f17": "actualizado {{value0}}",
"71a3c0f9d2": "Iniciar espacio de trabajo"
"71a3c0f9d2": "Iniciar espacio de trabajo",
"filesUnavailable": "Couldn't load changed files.",
"filesRetry": "Retry"
},
"QuickOpen": {
"1dbd3f59ff": "Mover",
@ -3213,6 +3215,13 @@
"usagePercentageLabel": {
"used": "{{value0}}% usado",
"remaining": "{{value0}}% restante"
},
"UsagePercentageDisplayChangeNotice": {
"title": "Usage now shows % used",
"body": "Prefer remaining? Change it in Settings.",
"dismiss": "Dismiss",
"openSettings": "Open Settings",
"gotIt": "Got it"
}
}
},
@ -9008,6 +9017,10 @@
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "Usado",
"usagePercentageDisplayRemaining": "Restante"
},
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
}
},
"right": {
@ -9877,7 +9890,9 @@
"23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer.",
"8b8ee9d22f": "No se pudo determinar qué host posee este archivo. Comprueba la conexión del espacio de trabajo e inténtalo de nuevo.",
"af1270b90d": "¿Eliminar {{count}} elementos permanentemente?",
"dd029aa5cd": "Esto elimina permanentemente los elementos seleccionados y el contenido de cualquier directorio en el host remoto. Esto no se puede deshacer."
"dd029aa5cd": "Esto elimina permanentemente los elementos seleccionados y el contenido de cualquier directorio en el host remoto. Esto no se puede deshacer.",
"77fdc36183": "Delete {{count}} items?",
"fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}."
},
"useFileExplorerHandlers": {
"32cd9fd991": "No se puede abrir el destino del enlace simbólico"

View File

@ -1387,7 +1387,9 @@
"1ff5d979df": "担当者なし",
"checkActionRequiredHint": "マージを解除するには、このチェックにGitHub上で手動アクションワークフロー実行の承認が必要です。",
"dd5d9a4f17": "{{value0}}に更新",
"71a3c0f9d2": "ワークスペースを開始"
"71a3c0f9d2": "ワークスペースを開始",
"filesUnavailable": "Couldn't load changed files.",
"filesRetry": "Retry"
},
"QuickOpen": {
"1dbd3f59ff": "移動",
@ -3213,6 +3215,13 @@
"usagePercentageLabel": {
"used": "{{value0}}% 使用済み",
"remaining": "残り {{value0}}%"
},
"UsagePercentageDisplayChangeNotice": {
"title": "Usage now shows % used",
"body": "Prefer remaining? Change it in Settings.",
"dismiss": "Dismiss",
"openSettings": "Open Settings",
"gotIt": "Got it"
}
}
},
@ -9008,6 +9017,10 @@
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "使用済み",
"usagePercentageDisplayRemaining": "残り"
},
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
}
},
"right": {
@ -9877,7 +9890,9 @@
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.",
"8b8ee9d22f": "このファイルを所有しているホストを特定できませんでした。ワークスペースの接続を確認して、もう一度お試しください。",
"af1270b90d": "{{count}}個の項目を完全に削除しますか?",
"dd029aa5cd": "選択した項目とディレクトリの内容がリモートホスト上で完全に削除されます。この操作は元に戻せません。"
"dd029aa5cd": "選択した項目とディレクトリの内容がリモートホスト上で完全に削除されます。この操作は元に戻せません。",
"77fdc36183": "Delete {{count}} items?",
"fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}."
},
"useFileExplorerHandlers": {
"32cd9fd991": "シンボリックリンクターゲットを開けません"

View File

@ -1387,7 +1387,9 @@
"1ff5d979df": "할당된 사람이 없음",
"checkActionRequiredHint": "머지가 해제되려면 이 검사에 대해 GitHub에서 수동 작업(예: 워크플로우 실행 승인)이 필요합니다.",
"dd5d9a4f17": "{{value0}}에 업데이트됨",
"71a3c0f9d2": "워크스페이스 시작"
"71a3c0f9d2": "워크스페이스 시작",
"filesUnavailable": "Couldn't load changed files.",
"filesRetry": "Retry"
},
"QuickOpen": {
"1dbd3f59ff": "이동",
@ -3213,6 +3215,13 @@
"usagePercentageLabel": {
"used": "{{value0}}% 사용",
"remaining": "{{value0}}% 남음"
},
"UsagePercentageDisplayChangeNotice": {
"title": "Usage now shows % used",
"body": "Prefer remaining? Change it in Settings.",
"dismiss": "Dismiss",
"openSettings": "Open Settings",
"gotIt": "Got it"
}
}
},
@ -9008,6 +9017,10 @@
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "사용",
"usagePercentageDisplayRemaining": "남음"
},
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
}
},
"right": {
@ -9877,7 +9890,9 @@
"23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
"8b8ee9d22f": "이 파일을 소유한 호스트를 확인할 수 없습니다. 워크스페이스 연결을 확인한 후 다시 시도하세요.",
"af1270b90d": "{{count}}개 항목을 영구적으로 삭제하시겠습니까?",
"dd029aa5cd": "원격 호스트의 선택한 항목과 디렉터리 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다."
"dd029aa5cd": "원격 호스트의 선택한 항목과 디렉터리 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
"77fdc36183": "Delete {{count}} items?",
"fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}."
},
"useFileExplorerHandlers": {
"32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다"

View File

@ -1387,7 +1387,9 @@
"1ff5d979df": "未分配任何人",
"checkActionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如,批准工作流运行),然后才能解除合并阻止。",
"dd5d9a4f17": "{{value0}}更新",
"71a3c0f9d2": "启动工作区"
"71a3c0f9d2": "启动工作区",
"filesUnavailable": "Couldn't load changed files.",
"filesRetry": "Retry"
},
"QuickOpen": {
"1dbd3f59ff": "移动",
@ -3213,6 +3215,13 @@
"usagePercentageLabel": {
"used": "已用 {{value0}}%",
"remaining": "剩余 {{value0}}%"
},
"UsagePercentageDisplayChangeNotice": {
"title": "Usage now shows % used",
"body": "Prefer remaining? Change it in Settings.",
"dismiss": "Dismiss",
"openSettings": "Open Settings",
"gotIt": "Got it"
}
}
},
@ -9008,6 +9017,10 @@
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "已用",
"usagePercentageDisplayRemaining": "剩余"
},
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
}
},
"right": {
@ -9877,7 +9890,9 @@
"23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。",
"8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。",
"af1270b90d": "永久删除 {{count}} 项?",
"dd029aa5cd": "这会永久删除远程主机上所选的各项及任何目录内容。此操作无法撤销。"
"dd029aa5cd": "这会永久删除远程主机上所选的各项及任何目录内容。此操作无法撤销。",
"77fdc36183": "Delete {{count}} items?",
"fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}."
},
"useFileExplorerHandlers": {
"32cd9fd991": "无法打开符号链接目标"

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
clampUsedPercent,
getDisplayedUsagePercentage,
normalizeUsagePercentageDisplay
} from './usage-percentage-display'
@ -17,9 +18,30 @@ describe('usage percentage display', () => {
it('rounds and bounds percentages for display', () => {
expect(getDisplayedUsagePercentage(20.5, 'used')).toBe(21)
expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(80)
// Complement is taken from the rounded used value (21), so remaining is 79 —
// it must not round the complement independently to 80. (#7574)
expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(79)
expect(getDisplayedUsagePercentage(120, 'remaining')).toBe(0)
expect(getDisplayedUsagePercentage(-20, 'used')).toBe(0)
expect(getDisplayedUsagePercentage(Number.NaN, 'remaining')).toBe(0)
})
it('clamps non-finite provider values to 0 for bar width and labels', () => {
// Why: Math.round/min/max propagate NaN into CSS width (`NaN%`) and copy.
expect(clampUsedPercent(Number.NaN)).toBe(0)
expect(clampUsedPercent(Number.POSITIVE_INFINITY)).toBe(0)
expect(clampUsedPercent(Number.NEGATIVE_INFINITY)).toBe(0)
})
it('agrees whether given a raw or pre-clamped used percent (#7574)', () => {
// Finite inputs only: non-finite clamp→0 vs getDisplayedUsagePercentage→0
// diverge for remaining (100 vs 0), so that case is covered separately above.
for (const raw of [20.5, 6.5, 79.5, 0.5, 99.5]) {
for (const display of ['used', 'remaining'] as const) {
expect(getDisplayedUsagePercentage(clampUsedPercent(raw), display)).toBe(
getDisplayedUsagePercentage(raw, display)
)
}
}
})
})

View File

@ -7,6 +7,16 @@ export function normalizeUsagePercentageDisplay(value: unknown): UsagePercentage
return value === 'used' || value === 'remaining' ? value : DEFAULT_USAGE_PERCENTAGE_DISPLAY
}
// Why: single clamp+round for bar width and label so the status bar and tooltip
// share one rounding, and feeding it into getDisplayedUsagePercentage stays a
// no-op — a pre-clamped value and a raw one resolve identically (#7574).
export function clampUsedPercent(usedPercent: number): number {
if (!Number.isFinite(usedPercent)) {
return 0
}
return Math.max(0, Math.min(100, Math.round(usedPercent)))
}
export function getDisplayedUsagePercentage(
usedPercent: number,
display: UsagePercentageDisplay
@ -16,6 +26,11 @@ export function getDisplayedUsagePercentage(
return 0
}
const boundedUsedPercent = Math.min(100, Math.max(0, usedPercent))
const percentage = display === 'used' ? boundedUsedPercent : 100 - boundedUsedPercent
return Math.round(percentage)
// Why: round the used value *before* taking the `remaining` complement so the
// result is stable whether the caller passes a raw usedPercent (compact status
// bar) or one already through clampUsedPercent (tooltip). Rounding after the
// complement makes `Math.round(100 - 20.5)` (80) disagree with the pre-rounded
// `100 - Math.round(20.5)` (79) at a .5 fraction — the 1% drift in #7574.
const roundedUsedPercent = Math.round(boundedUsedPercent)
return display === 'used' ? roundedUsedPercent : 100 - roundedUsedPercent
}

View File

@ -9,12 +9,37 @@ describe('extractWorkIdentifier', () => {
})
})
it('reads a Bitbucket pull-requests URL', () => {
it('reads a Bitbucket Cloud pull-requests URL', () => {
expect(
extractWorkIdentifier('Look at https://bitbucket.org/team/repo/pull-requests/77')?.label
).toBe('PR 77')
})
it('reads a Bitbucket Server pull-requests URL', () => {
expect(
extractWorkIdentifier(
'Review https://bitbucket.example.com/projects/ENG/repos/orca/pull-requests/1288'
)
).toEqual({ label: 'PR 1288', tokens: ['pr', '1288'] })
// Personal (fork) repos live under /users instead of /projects.
expect(
extractWorkIdentifier(
'see https://bitbucket.example.com/users/jane/repos/orca/pull-requests/9/overview'
)?.label
).toBe('PR 9')
})
it('reads Azure DevOps pull request URLs (dev.azure.com and visualstudio.com)', () => {
expect(
extractWorkIdentifier('Look at https://dev.azure.com/contoso/Orca/_git/orca/pullrequest/4521')
).toEqual({ label: 'PR 4521', tokens: ['pr', '4521'] })
expect(
extractWorkIdentifier(
'https://contoso.visualstudio.com/Orca/_git/orca/pullrequest/4521?_a=files'
)?.label
).toBe('PR 4521')
})
it('reads a GitLab merge request URL as MR, and a work_items URL as an issue', () => {
expect(extractWorkIdentifier('Check https://gitlab.com/group/app/-/merge_requests/42')).toEqual(
{ label: 'MR 42', tokens: ['mr', '42'] }

View File

@ -53,7 +53,15 @@ const URL_IN_TEXT = /https?:\/\/[^\s<>()[\]"']+/gi
// also would not match the GitHub pattern, so ordering GitLab first is safe.
const GITLAB_ITEM_PATH = /\/-\/(issues|work_items|merge_requests)\/(\d+)(?:[/?#]|$)/i
const GITHUB_ITEM_PATH = /^\/[^/]+\/[^/]+\/(issues|pull)\/(\d+)(?:[/?#]|$)/i
const BITBUCKET_ITEM_PATH = /^\/[^/]+\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i
// Bitbucket Cloud: /workspace/repo/pull-requests/N
const BITBUCKET_CLOUD_ITEM_PATH = /^\/[^/]+\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i
// Bitbucket Server / Data Center nests the repo under a project or user, so the
// PR path carries more segments than Cloud: /projects/KEY/repos/REPO/pull-requests/N.
const BITBUCKET_SERVER_ITEM_PATH =
/\/(?:projects|users)\/[^/]+\/repos\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i
// Azure DevOps (dev.azure.com, *.visualstudio.com, on-prem collections) always
// routes a PR through /_git/REPO/pullrequest/N, regardless of org/project prefix.
const AZURE_DEVOPS_ITEM_PATH = /\/_git\/[^/]+\/pullrequests?\/(\d+)(?:[/?#]|$)/i
function taggedIdentifier(type: 'PR' | 'MR' | 'Issue', num: string): WorkIdentifier {
return { label: `${type} ${num}`, tokens: [type.toLowerCase(), num] }
@ -82,9 +90,17 @@ function urlToIdentifier(raw: string): WorkIdentifier | null {
? taggedIdentifier('PR', github[2])
: taggedIdentifier('Issue', github[2])
}
const bitbucket = BITBUCKET_ITEM_PATH.exec(path)
if (bitbucket) {
return taggedIdentifier('PR', bitbucket[1])
const bitbucketCloud = BITBUCKET_CLOUD_ITEM_PATH.exec(path)
if (bitbucketCloud) {
return taggedIdentifier('PR', bitbucketCloud[1])
}
const bitbucketServer = BITBUCKET_SERVER_ITEM_PATH.exec(path)
if (bitbucketServer) {
return taggedIdentifier('PR', bitbucketServer[1])
}
const azureDevops = AZURE_DEVOPS_ITEM_PATH.exec(path)
if (azureDevops) {
return taggedIdentifier('PR', azureDevops[1])
}
return null
}