Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 27 additions & 0 deletions tools/sdk-ai-bots/azure-sdk-qa-bot-knowledge-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,33 @@ The application uses `config/knowledge-config.json` to define which repositories
- Documentation paths within repositories
- Processing options and filters

`path` may select a directory or an individual Markdown/MDX file.
`relativeByRepoPath: true` keeps blob filenames relative to the repository root
for citations; scanning is limited to the selected `path`. Without a `path`,
processing still scans the entire available repository checkout. Ordinary sources
without `relativeByRepoPath` retain directory-relative filenames, or the basename
for an individual file. Existing Markdown/MDX, ignored-path, reference, dated
release-note, and metadata filters remain unchanged. Release-note index discovery
still checks the source root's `release-notes` directory, but skips it when outside
the selected path; it does not discover new nested release-note directories.

Previously, `relativeByRepoPath: true` used `path` only for sparse checkout and
scanned the shared repository root for every entry. Multiple entries sharing a
destination folder could produce duplicate blob keys and overwrite metadata.
Each entry now ingests only its configured path while preserving repository-relative
citations. Selected paths sharing a destination folder should not overlap,
especially when assigning different metadata.

This change applies globally to all `relativeByRepoPath` sources. Root or ancestor
Markdown incidentally fetched by sparse checkout but outside selected paths will
no longer be ingested. On a later authorized sync, `cleanupExpiredBlobs` can remove
previously indexed incidental blobs absent from the new results. Review the full
configuration and this migration impact before syncing; never run sync against a
partial source list, because cleanup removes blobs absent from its results.
Code/configuration changes alone do not sync, index, or deploy knowledge.
The companion .NET knowledge-source change, [#17253](https://github.com/Azure/azure-sdk-tools/pull/17253),
depends on this generic path-scoping change merging first.

## Building and Running

### Prerequisites
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ export async function processDailySyncKnowledge(): Promise<void> {
const sourceDir = path.join(workingDir, source.path);
const targetDir = path.join(tempDocsDir, source.folder);

if (!fs.existsSync(sourceDir)) {
console.warn(`Source directory not found: ${sourceDir}`);
const scanPath = source.includePath ? path.join(sourceDir, source.includePath) : sourceDir;
if (!fs.existsSync(scanPath)) {
console.warn(`Source path not found: ${scanPath}`);
continue;
}
Comment on lines +107 to 110

Expand Down Expand Up @@ -359,12 +360,12 @@ async function setupDocumentationRepositories(docsDir: string): Promise<void> {
/**
* Process files in a source directory
*/
async function processSourceDirectory(
export async function processSourceDirectory(
sourceDir: string,
source: DocumentationSource,
targetDir: string,
existingBlobs: Map<string, any>,
blobService: BlobService
blobService: Pick<BlobService, 'hasContentChanged' | 'hasMetadataChanged'>
): Promise<ProcessSourceDirectoryResult> {

let totalProcessed = 0;
Expand Down Expand Up @@ -422,11 +423,12 @@ async function processSourceDirectory(
}
};

// Check if sourceDir is actually a file on the filesystem
const isFile = fs.existsSync(sourceDir) && fs.statSync(sourceDir).isFile();
// Selection stays narrow while filenames remain relative to the repository root.
const scanPath = source.includePath ? path.join(sourceDir, source.includePath) : sourceDir;
const isFile = fs.statSync(scanPath).isFile();

if (isFile) {
processSingleFile(sourceDir, path.dirname(sourceDir));
processSingleFile(scanPath, source.includePath ? sourceDir : path.dirname(sourceDir));
Comment on lines 430 to +431

return { totalProcessed, changedDocuments, unchangedDocuments, changedFiles, metadataChangedFiles, unchangedFiles };
}
Expand Down Expand Up @@ -461,7 +463,7 @@ async function processSourceDirectory(
}
}

await walkDirectory(sourceDir);
await walkDirectory(scanPath);

return {
totalProcessed,
Expand All @@ -476,13 +478,21 @@ async function processSourceDirectory(
/**
* Create release notes index with content from the 10 most recent release notes
*/
async function createReleaseNotesIndex(
export async function createReleaseNotesIndex(
source: DocumentationSource,
sourceDir: string,
targetDir: string
): Promise<void> {
// Path to release notes directory
const releaseNotesDir = path.join(sourceDir, 'release-notes');

if (source.includePath) {
const selectedPath = path.join(sourceDir, source.includePath);
const relativeReleasePath = path.relative(selectedPath, releaseNotesDir);
if (relativeReleasePath.startsWith('..') || path.isAbsolute(relativeReleasePath)) {
return;
}
}

// Check if release notes directory exists
if (!fs.existsSync(releaseNotesDir)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface KnowledgeConfig {
*/
export interface DocumentationSource {
path: string;
includePath?: string;
folder: string;
fileNameLowerCase?: boolean;
ignoredPaths?: string[];
Expand Down Expand Up @@ -138,6 +139,7 @@ export class ConfigurationLoader {
for (const docPath of source.paths) {
sources.push({
path: (docPath.relativeByRepoPath || docPath.path === undefined) ? `docs/${repoPath}` : `docs/${repoPath}/${docPath.path}`,
includePath: docPath.relativeByRepoPath ? docPath.path?.replace(/^[/\\]+/, '') : undefined,
folder: docPath.folder,
fileNameLowerCase: docPath.fileNameLowerCase,
ignoredPaths: docPath.ignoredPaths,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,35 @@ describe('ConfigurationLoader', () => {
expect(repos[0].sparseCheckout).toEqual(['docs/part1', 'docs/part2']);
});

it('keeps repository-relative citation roots but selects only the configured path', () => {
const temp = writeTempConfig({
version: '1.0.0',
sources: [{
repository: { url: 'https://github.com/org/repo.git', branch: 'main', authType: 'public' },
paths: [
{ description: 'Docs', path: '/doc', folder: 'docs', relativeByRepoPath: true },
{ description: 'Readme', path: '/eng/tool/README.md', folder: 'docs', relativeByRepoPath: true },
{ description: 'Entire repo', folder: 'whole_repo', relativeByRepoPath: true }
]
}]
});
ConfigurationLoader.setConfigPath(temp);

const docs = ConfigurationLoader.getDocumentationSources();
expect(docs.map(source => source.path)).toEqual(['docs/repo', 'docs/repo', 'docs/repo']);
expect(docs.map(source => source.includePath)).toEqual(['doc', 'eng/tool/README.md', undefined]);
expect(ConfigurationLoader.getRepositoryConfigs()[0].sparseCheckout).toEqual([
'/doc', '/eng/tool/README.md'
]);
});

it('throws when file missing', () => {
const missing = path.join(process.cwd(), 'definitely-missing-config.json');
ConfigurationLoader.setConfigPath(missing);
expect(() => ConfigurationLoader.getDocumentationSources()).toThrow();
});
afterAll(() => {
ConfigurationLoader.setConfigPath(originalPath);
for (const dir of tempDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
Expand Down
Loading
Loading