Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,9 @@ interface IResolvedSessionIssue {
/** Builds Agents Window issue pill entries, enriching them when live details are available. */
export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue[], session: IActiveSession | undefined, commandService: ICommandService, clipboardService: IClipboardService, openerService: IOpenerService, sessionsService: ISessionsService): readonly IChatPillSection[] {
const entries = issues.map(({ ref, issue }) => {
const label = issue?.title
? localize('sessionChatPills.issueWithTitle', "Issue #{0}: {1}", ref.number, issue.title)
const title = issue?.title ?? ref.title;
const label = title
? localize('sessionChatPills.issueWithTitle', "Issue #{0}: {1}", ref.number, title)
: localize('sessionChatPills.issue', "Issue #{0}", ref.number);
return {
id: ref.uri.toString(),
Expand All @@ -157,6 +158,7 @@ export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue
run: () => clipboardService.writeText(ref.uri.toString(true)),
})],
...getChatPillResourceLocation(ref.uri, label),
...(!issue && ref.title ? { tooltip: `${label}\n${ref.uri.toString(true)}` } : {}),
...(issue ? {
pillHover: {
element: () => createIssueHoverElement({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ suite('SessionChatInputToolbar', () => {
repo: 'vscode',
number: 42,
uri: URI.parse('https://github.com/microsoft/vscode/issues/42'),
title: 'Recorded issue title',
};
const issue: IGitHubIssue = {
number: issueRef.number,
Expand Down Expand Up @@ -235,10 +236,14 @@ suite('SessionChatInputToolbar', () => {
unresolvedHover: unresolvedPullRequestEntry?.pillHover,
},
issue: {
label: issueEntry?.label,
className: issueHover?.className,
repository: issueHover?.querySelector('.sessions-issue-hover-repository')?.textContent,
title: issueHover?.querySelector('.sessions-issue-hover-title')?.textContent,
description: issueHover?.querySelector('.sessions-issue-hover-description-content')?.textContent,
unresolvedLabel: unresolvedIssueEntry?.label,
unresolvedAriaLabel: unresolvedIssueEntry?.ariaLabel,
unresolvedTooltip: unresolvedIssueEntry?.tooltip,
unresolvedHover: unresolvedIssueEntry?.pillHover,
},
}, {
Expand All @@ -251,10 +256,14 @@ suite('SessionChatInputToolbar', () => {
unresolvedHover: undefined,
},
issue: {
label: 'Issue #42: Rich issue hover',
className: 'sessions-issue-hover',
repository: 'microsoft/vscode#42',
title: 'Rich issue hover',
description: 'Provides detailed issue context.',
unresolvedLabel: 'Issue #42: Recorded issue title',
unresolvedAriaLabel: 'Open Issue #42: Recorded issue title',
unresolvedTooltip: 'Issue #42: Recorded issue title\nhttps://github.com/microsoft/vscode/issues/42',
unresolvedHover: undefined,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export interface ISessionArtifactPartition {
readonly pullRequestTitles: ReadonlyMap<string, string>;
/** Issues this session produced, most recent first. */
readonly issueUrls: readonly string[];
/** Titles the agent recorded for its issue artifacts, keyed by {@link linkKey}. */
readonly issueTitles: ReadonlyMap<string, string>;
}

interface ISessionArtifactEntry {
Expand Down Expand Up @@ -98,6 +100,7 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi
const pullRequestUrls: string[] = [];
const pullRequestTitles = new Map<string, string>();
const issueUrls: string[] = [];
const issueTitles = new Map<string, string>();

for (const artifact of readSessionArtifacts(meta)) {
const mapped = toSessionArtifact(artifact);
Expand All @@ -110,17 +113,17 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi
continue;
}

const titles = artifact.type === SessionArtifactType.Issue ? issueTitles : pullRequestTitles;
const key = linkKey(link);
if (mapped.label && !titles.has(key)) {
titles.set(key, mapped.label);
}

if (artifact.type === SessionArtifactType.Issue) {
issueUrls.push(link);
continue;
}

// The label an agent records for a pull request is its title; keep the
// first one so a later duplicate cannot rewrite it.
const key = linkKey(link);
if (mapped.label && !pullRequestTitles.has(key)) {
pullRequestTitles.set(key, mapped.label);
}
pullRequestUrls.push(link);
}

Expand All @@ -129,7 +132,7 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi
pullRequestUrls.reverse();
issueUrls.reverse();

return { entries, pullRequestUrls, pullRequestTitles, issueUrls };
return { entries, pullRequestUrls, pullRequestTitles, issueUrls, issueTitles };
}

/** Case-insensitive de-duplication that keeps the first occurrence's casing. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,12 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine
a.pullRequest?.title === b.pullRequest?.title &&
a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid &&
a.pullRequest?.headRefOid === b.pullRequest?.headRefOid &&
arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number);
arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) =>
x.owner === y.owner &&
x.repo === y.repo &&
x.number === y.number &&
isEqual(x.uri, y.uri) &&
x.title === y.title);
}

function dateEquals(a: Date | undefined, b: Date | undefined): boolean {
Expand All @@ -348,12 +353,17 @@ function markdownStringEquals(a: IMarkdownString | undefined, b: IMarkdownString
}

/** Maps the GitHub issue URLs recorded on the session's metadata to issue references. */
function toGitHubIssueRefs(issueUrls: readonly string[] | undefined): readonly IGitHubIssueRef[] | undefined {
function toGitHubIssueRefs(issueUrls: readonly string[] | undefined, titles: ReadonlyMap<string, string>): readonly IGitHubIssueRef[] | undefined {
const refs: IGitHubIssueRef[] = [];
for (const url of issueUrls ?? []) {
const reference = parseGitHubIssueUrl(url);
if (reference) {
refs.push({ ...reference, uri: URI.parse(url) });
const title = titles.get(linkKey(url));
refs.push({
...reference,
uri: URI.parse(url),
...(title ? { title } : {}),
});
}
}
return refs.length > 0 ? refs : undefined;
Expand Down Expand Up @@ -387,7 +397,7 @@ function toGitHubPullRequestRefs(state: ISessionGitHubState | undefined, pullReq
function toGitHubInfo(meta: SessionMeta | undefined): IGitHubInfo | undefined {
const state = readSessionGitHubState(meta);
const gitState = readSessionGitState(meta);
const { pullRequestUrls, pullRequestTitles, issueUrls } = partitionSessionArtifacts(meta);
const { pullRequestUrls, pullRequestTitles, issueUrls, issueTitles } = partitionSessionArtifacts(meta);

// Recorded pull requests lead discovered ones, so the first is the newest.
const allPullRequests = toGitHubPullRequestRefs(state, dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles);
Expand All @@ -408,7 +418,7 @@ function toGitHubInfo(meta: SessionMeta | undefined): IGitHubInfo | undefined {

const pullRequests = allPullRequests?.filter(belongsToRepository);
const pullRequest = pullRequests?.at(0);
const issues = toGitHubIssueRefs(dedupeLinks(issueUrls))?.filter(belongsToRepository);
const issues = toGitHubIssueRefs(dedupeLinks(issueUrls), issueTitles)?.filter(belongsToRepository);

return {
owner: repository.owner,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7824,7 +7824,7 @@ suite('LocalAgentHostSessionsProvider', () => {
{ id: 'a1', type: SessionArtifactType.PullRequest, label: 'Created', isArtifact: true, link: 'https://github.com/owner/repo/pull/50', isGitHub: true },
{ id: 'a2', type: SessionArtifactType.PullRequest, label: 'Referenced', isArtifact: false, link: 'https://github.com/owner/repo/pull/60', isGitHub: true },
{ id: 'a3', type: SessionArtifactType.PullRequest, label: 'Duplicate', isArtifact: true, link: 'https://github.com/OWNER/REPO/pull/41/', isGitHub: true },
{ id: 'a4', type: SessionArtifactType.Issue, label: 'Issue', isArtifact: true, link: 'https://github.com/owner/repo/issues/7', isGitHub: true },
{ id: 'a4', type: SessionArtifactType.Issue, label: 'Preserve promoted issue titles', isArtifact: true, link: 'https://github.com/owner/repo/issues/7', isGitHub: true },
{ id: 'a5', type: SessionArtifactType.PullRequest, label: 'Elsewhere', isArtifact: true, link: 'https://gitlab.com/owner/repo/-/merge_requests/3', isGitHub: false },
{ id: 'a6', type: SessionArtifactType.File, label: 'Plan', isArtifact: true, uri: 'file:///repo/plan.md' },
{ id: 'a7', type: SessionArtifactType.Issue, label: 'Referenced issue', isArtifact: false, link: 'https://github.com/owner/repo/issues/8', isGitHub: true },
Expand All @@ -7842,13 +7842,13 @@ suite('LocalAgentHostSessionsProvider', () => {
assert.deepStrictEqual({
activePullRequest: gitHubInfo?.pullRequest?.number,
pullRequests: gitHubInfo?.pullRequests?.map(pullRequest => pullRequest.number),
issues: gitHubInfo?.issues?.map(issue => issue.number),
issues: gitHubInfo?.issues?.map(issue => [issue.number, issue.title]),
artifacts: session.artifacts?.get().map(artifact => [artifact.id, artifact.isArtifact]),
}, {
activePullRequest: 41,
pullRequests: [41, 50, 42],
// Only issues the session produced are polled; a referenced one stays a reference.
issues: [7],
issues: [[7, 'Preserve promoted issue titles']],
artifacts: [
['a8', false],
['a7', false],
Expand Down
10 changes: 9 additions & 1 deletion src/vs/sessions/services/sessions/common/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ export interface IGitHubIssueRef {
readonly number: number;
/** URI of the issue. */
readonly uri: URI;
/** Issue title recorded by the session, when known. */
readonly title?: string;
}

export interface ISessionChangesSummary {
Expand Down Expand Up @@ -1050,7 +1052,13 @@ export function gitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | und
(aIcon === bIcon || (!!aIcon && !!bIcon && ThemeIcon.isEqual(aIcon, bIcon))) &&
a.pullRequest?.title === b.pullRequest?.title &&
a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid &&
a.pullRequest?.headRefOid === b.pullRequest?.headRefOid;
a.pullRequest?.headRefOid === b.pullRequest?.headRefOid &&
arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) =>
x.owner === y.owner &&
x.repo === y.repo &&
x.number === y.number &&
isEqual(x.uri, y.uri) &&
x.title === y.title);
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/vs/sessions/services/sessions/test/common/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,23 @@ suite('sessionWorkspaceEqual', () => {
assert.strictEqual(sessionWorkspaceEqual(workspace('main', constObservable(gitHubInfoA)), workspace('main', constObservable(gitHubInfoB))), true);
});

test('compares recorded issue titles in GitHub info', () => {
const uri = URI.parse('https://github.com/owner/repo/issues/42');
const base: IGitHubInfo = {
owner: 'owner',
repo: 'repo',
issues: [{ owner: 'owner', repo: 'repo', number: 42, uri, title: 'Recorded title' }],
};

assert.deepStrictEqual({
equivalent: sessionWorkspaceEqual(workspace('main', constObservable(base)), workspace('main', constObservable({ ...base, issues: [{ ...base.issues![0] }] }))),
changedTitle: sessionWorkspaceEqual(workspace('main', constObservable(base)), workspace('main', constObservable({ ...base, issues: [{ ...base.issues![0], title: 'Updated title' }] }))),
}, {
equivalent: true,
changedTitle: false,
});
});

test('returns false when folder repository metadata changes', () => {
assert.strictEqual(sessionWorkspaceEqual(workspace('main'), workspace('feature')), false);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const artifactSectionOrder: readonly { readonly type: SessionArtifactType; reado
export interface IAgentHostSessionPillMetadata {
readonly pullRequestUrls: readonly string[];
readonly issueUrls: readonly string[];
readonly issueTitles: ReadonlyMap<string, string>;
readonly artifacts: readonly ISessionArtifact[];
readonly references: readonly ISessionArtifact[];
}
Expand Down Expand Up @@ -112,15 +113,17 @@ export function getAgentHostSessionPillMetadata(meta: SessionSummaryMeta | undef
const entries = readSessionArtifactsNewestFirst(meta);
const github = readSessionGitHubState(meta);
const artifactPullRequests = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.PullRequest)).map(entry => entry.link);
const artifactIssues = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.Issue)).map(entry => entry.link);
const artifactIssues = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.Issue));
// Recorded pull requests lead discovered ones, as in the Agents Window.
const pullRequestUrls = dedupeLinks(artifactPullRequests, getSessionRelatedPullRequestUrls(github));
const issueUrls = dedupeLinks(artifactIssues);
const issueUrls = dedupeLinks(artifactIssues.map(entry => entry.link));
const issueTitles = new Map(artifactIssues.map(entry => [linkKey(entry.link), entry.label]));
const promotedLinks = new Set([...pullRequestUrls, ...issueUrls].map(linkKey));
const remaining = entries.filter(entry => !entry.link || !promotedLinks.has(linkKey(entry.link)));
return {
pullRequestUrls,
issueUrls,
issueTitles,
artifacts: remaining.filter(entry => entry.isArtifact),
references: remaining.filter(entry => !entry.isArtifact),
};
Expand Down Expand Up @@ -204,14 +207,19 @@ function parseUri(value: string | undefined): URI | undefined {
}
}

function referenceLabel(link: string, kind: 'pullRequest' | 'issue'): string {
function referenceLabel(link: string, kind: 'pullRequest' | 'issue', title?: string): string {
const resource = parseUri(link);
const number = resource ? githubReferenceNumber(resource, kind) : undefined;
if (kind === 'pullRequest') {
return number
? localize('agentHostSessionPills.pullRequest.number', "Pull Request #{0}", number)
: localize('agentHostSessionPills.pullRequest', "Pull Request");
}
if (title) {
return number
? localize('agentHostSessionPills.issue.numberWithTitle', "Issue #{0}: {1}", number, title)
: title;
}
return number
? localize('agentHostSessionPills.issue.number', "Issue #{0}", number)
: localize('agentHostSessionPills.issue', "Issue");
Expand Down Expand Up @@ -350,7 +358,10 @@ export class AgentHostSessionInputPills extends Disposable {
const icons = getChatPillEntries(pullRequestSections.read(reader)).map(entry => entry.icon);
return getHighestPriorityPullRequestIcon(icons) ?? computePullRequestIcon('open');
});
const issueSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).issueUrls, 'issue'));
const issueSections = derived(this, reader => {
const currentMetadata = metadata.read(reader);
return this._buildReferenceSections(currentMetadata.issueUrls, 'issue', undefined, currentMetadata.issueTitles);
});
const artifactSections = derived(this, reader => {
const currentResolution = resolution.read(reader);
return currentResolution
Expand Down Expand Up @@ -404,14 +415,15 @@ export class AgentHostSessionInputPills extends Disposable {
updateVisibility(inputPills.visible);
}

private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType<typeof readSessionGitHubState>) {
private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType<typeof readSessionGitHubState>, titles?: ReadonlyMap<string, string>) {
const entries = links.map(link => {
const resource = parseUri(link);
if (!resource) {
return undefined;
}
const number = githubReferenceNumber(resource, kind);
const label = referenceLabel(link, kind);
const title = titles?.get(linkKey(link));
const label = referenceLabel(link, kind, title);
const pullRequestState = kind === 'pullRequest'
&& gitHubState?.pullRequestState
&& gitHubState.pullRequestStateUrl
Expand All @@ -433,6 +445,7 @@ export class AgentHostSessionInputPills extends Disposable {
run: () => this._clipboardService.writeText(resource.toString(true)),
})],
...getChatPillResourceLocation(resource, label),
...(kind === 'issue' && title ? { tooltip: `${label}\n${resource.toString(true)}` } : {}),
open: () => this._openExternal(resource),
};
}).filter(isDefined);
Expand Down
Loading