mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 13:25:21 +00:00
Add branch table to repos detail view
This commit is contained in:
parent
c16ef08a7c
commit
5a670bfdb9
12 changed files with 342 additions and 94 deletions
|
|
@ -278,6 +278,16 @@ export const getCommitHashForRefName = async ({
|
||||||
refName: string,
|
refName: string,
|
||||||
}) => {
|
}) => {
|
||||||
const git = createGitClientForPath(path);
|
const git = createGitClientForPath(path);
|
||||||
const rev = await git.revparse(refName);
|
|
||||||
return rev;
|
try {
|
||||||
|
// The `^{commit}` suffix is used to fully dereference the ref to a commit hash.
|
||||||
|
const rev = await git.revparse(`${refName}^{commit}`);
|
||||||
|
return rev;
|
||||||
|
|
||||||
|
// @note: Was hitting errors when the repository is empty,
|
||||||
|
// so we're catching the error and returning undefined.
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13,12 +13,12 @@ import { marshalBool } from "./utils.js";
|
||||||
import { createLogger } from '@sourcebot/logger';
|
import { createLogger } from '@sourcebot/logger';
|
||||||
import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||||
import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
|
import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
|
||||||
import { RepoMetadata } from './types.js';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { glob } from 'glob';
|
import { glob } from 'glob';
|
||||||
import { getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js';
|
import { getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import GitUrlParse from 'git-url-parse';
|
import GitUrlParse from 'git-url-parse';
|
||||||
|
import { RepoMetadata } from '@sourcebot/shared';
|
||||||
|
|
||||||
export type RepoData = WithRequired<Prisma.RepoCreateInput, 'connections'>;
|
export type RepoData = WithRequired<Prisma.RepoCreateInput, 'connections'>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
|
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
|
||||||
import { createLogger, Logger } from "@sourcebot/logger";
|
import { createLogger, Logger } from "@sourcebot/logger";
|
||||||
|
import { repoMetadataSchema, RepoIndexingJobMetadata, repoIndexingJobMetadataSchema, RepoMetadata } from '@sourcebot/shared';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { readdir, rm } from 'fs/promises';
|
import { readdir, rm } from 'fs/promises';
|
||||||
import { Job, Queue, ReservedJob, Worker } from "groupmq";
|
import { Job, Queue, ReservedJob, Worker } from "groupmq";
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
|
import micromatch from 'micromatch';
|
||||||
import { INDEX_CACHE_DIR } from './constants.js';
|
import { INDEX_CACHE_DIR } from './constants.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { cloneRepository, fetchRepository, getCommitHashForRefName, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js';
|
import { cloneRepository, fetchRepository, getBranches, getCommitHashForRefName, getTags, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js';
|
||||||
|
import { captureEvent } from './posthog.js';
|
||||||
import { PromClient } from './promClient.js';
|
import { PromClient } from './promClient.js';
|
||||||
import { repoMetadataSchema, RepoWithConnections, Settings } from "./types.js";
|
import { RepoWithConnections, Settings } from "./types.js";
|
||||||
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js';
|
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js';
|
||||||
import { indexGitRepository } from './zoekt.js';
|
import { indexGitRepository } from './zoekt.js';
|
||||||
|
|
||||||
|
|
@ -61,7 +64,7 @@ export class RepoIndexManager {
|
||||||
concurrency: this.settings.maxRepoIndexingJobConcurrency,
|
concurrency: this.settings.maxRepoIndexingJobConcurrency,
|
||||||
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
|
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
|
||||||
logger: true,
|
logger: true,
|
||||||
}: {}),
|
} : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.worker.on('completed', this.onJobCompleted.bind(this));
|
this.worker.on('completed', this.onJobCompleted.bind(this));
|
||||||
|
|
@ -263,7 +266,16 @@ export class RepoIndexManager {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (jobType === RepoIndexingJobType.INDEX) {
|
if (jobType === RepoIndexingJobType.INDEX) {
|
||||||
await this.indexRepository(repo, logger, abortController.signal);
|
const revisions = await this.indexRepository(repo, logger, abortController.signal);
|
||||||
|
|
||||||
|
await this.db.repoIndexingJob.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
metadata: {
|
||||||
|
indexedRevisions: revisions,
|
||||||
|
} satisfies RepoIndexingJobMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else if (jobType === RepoIndexingJobType.CLEANUP) {
|
} else if (jobType === RepoIndexingJobType.CLEANUP) {
|
||||||
await this.cleanupRepository(repo, logger);
|
await this.cleanupRepository(repo, logger);
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +297,7 @@ export class RepoIndexManager {
|
||||||
// If the repo path exists but it is not a valid git repository root, this indicates
|
// If the repo path exists but it is not a valid git repository root, this indicates
|
||||||
// that the repository is in a bad state. To fix, we remove the directory and perform
|
// that the repository is in a bad state. To fix, we remove the directory and perform
|
||||||
// a fresh clone.
|
// a fresh clone.
|
||||||
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot( { path: repoPath } ))) {
|
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot({ path: repoPath }))) {
|
||||||
const isValidGitRepo = await isPathAValidGitRepoRoot({
|
const isValidGitRepo = await isPathAValidGitRepoRoot({
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -354,10 +366,54 @@ export class RepoIndexManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let revisions = [
|
||||||
|
'HEAD'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (metadata.branches) {
|
||||||
|
const branchGlobs = metadata.branches
|
||||||
|
const allBranches = await getBranches(repoPath);
|
||||||
|
const matchingBranches =
|
||||||
|
allBranches
|
||||||
|
.filter((branch) => micromatch.isMatch(branch, branchGlobs))
|
||||||
|
.map((branch) => `refs/heads/${branch}`);
|
||||||
|
|
||||||
|
revisions = [
|
||||||
|
...revisions,
|
||||||
|
...matchingBranches
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.tags) {
|
||||||
|
const tagGlobs = metadata.tags;
|
||||||
|
const allTags = await getTags(repoPath);
|
||||||
|
const matchingTags =
|
||||||
|
allTags
|
||||||
|
.filter((tag) => micromatch.isMatch(tag, tagGlobs))
|
||||||
|
.map((tag) => `refs/tags/${tag}`);
|
||||||
|
|
||||||
|
revisions = [
|
||||||
|
...revisions,
|
||||||
|
...matchingTags
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoekt has a limit of 64 branches/tags to index.
|
||||||
|
if (revisions.length > 64) {
|
||||||
|
logger.warn(`Too many revisions (${revisions.length}) for repo ${repo.id}, truncating to 64`);
|
||||||
|
captureEvent('backend_revisions_truncated', {
|
||||||
|
repoId: repo.id,
|
||||||
|
revisionCount: revisions.length,
|
||||||
|
});
|
||||||
|
revisions = revisions.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Indexing ${repo.name} (id: ${repo.id})...`);
|
logger.info(`Indexing ${repo.name} (id: ${repo.id})...`);
|
||||||
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, signal));
|
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, revisions, signal));
|
||||||
const indexDuration_s = durationMs / 1000;
|
const indexDuration_s = durationMs / 1000;
|
||||||
logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`);
|
logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`);
|
||||||
|
|
||||||
|
return revisions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupRepository(repo: Repo, logger: Logger) {
|
private async cleanupRepository(repo: Repo, logger: Logger) {
|
||||||
|
|
@ -398,12 +454,18 @@ export class RepoIndexManager {
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
refName: 'HEAD',
|
refName: 'HEAD',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const jobMetadata = repoIndexingJobMetadataSchema.parse(jobData.metadata);
|
||||||
|
|
||||||
const repo = await this.db.repo.update({
|
const repo = await this.db.repo.update({
|
||||||
where: { id: jobData.repoId },
|
where: { id: jobData.repoId },
|
||||||
data: {
|
data: {
|
||||||
indexedAt: new Date(),
|
indexedAt: new Date(),
|
||||||
indexedCommitHash: commitHash,
|
indexedCommitHash: commitHash,
|
||||||
|
metadata: {
|
||||||
|
...(jobData.repo.metadata as RepoMetadata),
|
||||||
|
indexedRevisions: jobMetadata.indexedRevisions,
|
||||||
|
} satisfies RepoMetadata,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,8 @@
|
||||||
import { Connection, Repo, RepoToConnection } from "@sourcebot/db";
|
import { Connection, Repo, RepoToConnection } from "@sourcebot/db";
|
||||||
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export type Settings = Required<SettingsSchema>;
|
export type Settings = Required<SettingsSchema>;
|
||||||
|
|
||||||
// Structure of the `metadata` field in the `Repo` table.
|
|
||||||
//
|
|
||||||
// @WARNING: If you modify this schema, please make sure it is backwards
|
|
||||||
// compatible with any prior versions of the schema!!
|
|
||||||
// @NOTE: If you move this schema, please update the comment in schema.prisma
|
|
||||||
// to point to the new location.
|
|
||||||
export const repoMetadataSchema = z.object({
|
|
||||||
/**
|
|
||||||
* A set of key-value pairs that will be used as git config
|
|
||||||
* variables when cloning the repo.
|
|
||||||
* @see: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--configcodecodeltkeygtltvaluegtcode
|
|
||||||
*/
|
|
||||||
gitConfig: z.record(z.string(), z.string()).optional(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of branches to index. Glob patterns are supported.
|
|
||||||
*/
|
|
||||||
branches: z.array(z.string()).optional(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of tags to index. Glob patterns are supported.
|
|
||||||
*/
|
|
||||||
tags: z.array(z.string()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RepoMetadata = z.infer<typeof repoMetadataSchema>;
|
|
||||||
|
|
||||||
// @see : https://stackoverflow.com/a/61132308
|
// @see : https://stackoverflow.com/a/61132308
|
||||||
export type DeepPartial<T> = T extends object ? {
|
export type DeepPartial<T> = T extends object ? {
|
||||||
[P in keyof T]?: DeepPartial<T[P]>;
|
[P in keyof T]?: DeepPartial<T[P]>;
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,16 @@
|
||||||
import { Repo } from "@sourcebot/db";
|
import { Repo } from "@sourcebot/db";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import micromatch from "micromatch";
|
|
||||||
import { INDEX_CACHE_DIR } from "./constants.js";
|
import { INDEX_CACHE_DIR } from "./constants.js";
|
||||||
import { getBranches, getTags } from "./git.js";
|
import { Settings } from "./types.js";
|
||||||
import { captureEvent } from "./posthog.js";
|
|
||||||
import { repoMetadataSchema, Settings } from "./types.js";
|
|
||||||
import { getRepoPath, getShardPrefix } from "./utils.js";
|
import { getRepoPath, getShardPrefix } from "./utils.js";
|
||||||
|
|
||||||
const logger = createLogger('zoekt');
|
const logger = createLogger('zoekt');
|
||||||
|
|
||||||
export const indexGitRepository = async (repo: Repo, settings: Settings, signal?: AbortSignal) => {
|
export const indexGitRepository = async (repo: Repo, settings: Settings, revisions: string[], signal?: AbortSignal) => {
|
||||||
let revisions = [
|
|
||||||
'HEAD'
|
|
||||||
];
|
|
||||||
|
|
||||||
const { path: repoPath } = getRepoPath(repo);
|
const { path: repoPath } = getRepoPath(repo);
|
||||||
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
||||||
const metadata = repoMetadataSchema.parse(repo.metadata);
|
|
||||||
|
|
||||||
if (metadata.branches) {
|
|
||||||
const branchGlobs = metadata.branches
|
|
||||||
const allBranches = await getBranches(repoPath);
|
|
||||||
const matchingBranches =
|
|
||||||
allBranches
|
|
||||||
.filter((branch) => micromatch.isMatch(branch, branchGlobs))
|
|
||||||
.map((branch) => `refs/heads/${branch}`);
|
|
||||||
|
|
||||||
revisions = [
|
|
||||||
...revisions,
|
|
||||||
...matchingBranches
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.tags) {
|
|
||||||
const tagGlobs = metadata.tags;
|
|
||||||
const allTags = await getTags(repoPath);
|
|
||||||
const matchingTags =
|
|
||||||
allTags
|
|
||||||
.filter((tag) => micromatch.isMatch(tag, tagGlobs))
|
|
||||||
.map((tag) => `refs/tags/${tag}`);
|
|
||||||
|
|
||||||
revisions = [
|
|
||||||
...revisions,
|
|
||||||
...matchingTags
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// zoekt has a limit of 64 branches/tags to index.
|
|
||||||
if (revisions.length > 64) {
|
|
||||||
logger.warn(`Too many revisions (${revisions.length}) for repo ${repo.id}, truncating to 64`);
|
|
||||||
captureEvent('backend_revisions_truncated', {
|
|
||||||
repoId: repo.id,
|
|
||||||
revisionCount: revisions.length,
|
|
||||||
});
|
|
||||||
revisions = revisions.slice(0, 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = [
|
const command = [
|
||||||
'zoekt-git-index',
|
'zoekt-git-index',
|
||||||
'-allow_missing_branches',
|
'-allow_missing_branches',
|
||||||
|
|
@ -76,7 +30,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, signal?
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
stdout.split('\n').filter(line => line.trim()).forEach(line => {
|
stdout.split('\n').filter(line => line.trim()).forEach(line => {
|
||||||
logger.info(line);
|
logger.info(line);
|
||||||
|
|
@ -89,7 +43,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, signal?
|
||||||
logger.info(line);
|
logger.info(line);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
stdout,
|
stdout,
|
||||||
stderr
|
stderr
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RepoIndexingJob" ADD COLUMN "metadata" JSONB;
|
||||||
|
|
@ -38,7 +38,7 @@ model Repo {
|
||||||
isFork Boolean
|
isFork Boolean
|
||||||
isArchived Boolean
|
isArchived Boolean
|
||||||
isPublic Boolean @default(false)
|
isPublic Boolean @default(false)
|
||||||
metadata Json /// For schema see repoMetadataSchema in packages/backend/src/types.ts
|
metadata Json /// For schema see repoMetadataSchema in packages/shared/src/types.ts
|
||||||
cloneUrl String
|
cloneUrl String
|
||||||
webUrl String?
|
webUrl String?
|
||||||
connections RepoToConnection[]
|
connections RepoToConnection[]
|
||||||
|
|
@ -84,6 +84,7 @@ model RepoIndexingJob {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
metadata Json? /// For schema see repoIndexingJobMetadataSchema in packages/shared/src/types.ts
|
||||||
|
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ export type {
|
||||||
Plan,
|
Plan,
|
||||||
Entitlement,
|
Entitlement,
|
||||||
} from "./entitlements.js";
|
} from "./entitlements.js";
|
||||||
|
export type {
|
||||||
|
RepoMetadata,
|
||||||
|
RepoIndexingJobMetadata,
|
||||||
|
} from "./types.js";
|
||||||
|
export {
|
||||||
|
repoMetadataSchema,
|
||||||
|
repoIndexingJobMetadataSchema,
|
||||||
|
} from "./types.js";
|
||||||
export {
|
export {
|
||||||
base64Decode,
|
base64Decode,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,45 @@
|
||||||
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export type ConfigSettings = Required<SettingsSchema>;
|
export type ConfigSettings = Required<SettingsSchema>;
|
||||||
|
|
||||||
|
// Structure of the `metadata` field in the `Repo` table.
|
||||||
|
//
|
||||||
|
// @WARNING: If you modify this schema, please make sure it is backwards
|
||||||
|
// compatible with any prior versions of the schema!!
|
||||||
|
// @NOTE: If you move this schema, please update the comment in schema.prisma
|
||||||
|
// to point to the new location.
|
||||||
|
export const repoMetadataSchema = z.object({
|
||||||
|
/**
|
||||||
|
* A set of key-value pairs that will be used as git config
|
||||||
|
* variables when cloning the repo.
|
||||||
|
* @see: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--configcodecodeltkeygtltvaluegtcode
|
||||||
|
*/
|
||||||
|
gitConfig: z.record(z.string(), z.string()).optional(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of branches to index. Glob patterns are supported.
|
||||||
|
*/
|
||||||
|
branches: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of tags to index. Glob patterns are supported.
|
||||||
|
*/
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of revisions that were indexed for the repo.
|
||||||
|
*/
|
||||||
|
indexedRevisions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RepoMetadata = z.infer<typeof repoMetadataSchema>;
|
||||||
|
|
||||||
|
export const repoIndexingJobMetadataSchema = z.object({
|
||||||
|
/**
|
||||||
|
* A list of revisions that were indexed for the repo.
|
||||||
|
*/
|
||||||
|
indexedRevisions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RepoIndexingJobMetadata = z.infer<typeof repoIndexingJobMetadataSchema>;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { RepoJobsTable } from "../components/repoJobsTable"
|
||||||
import { getConfigSettings } from "@sourcebot/shared"
|
import { getConfigSettings } from "@sourcebot/shared"
|
||||||
import { env } from "@/env.mjs"
|
import { env } from "@/env.mjs"
|
||||||
import { DisplayDate } from "../../components/DisplayDate"
|
import { DisplayDate } from "../../components/DisplayDate"
|
||||||
|
import { RepoBranchesTable } from "../components/repoBranchesTable"
|
||||||
|
import { repoMetadataSchema } from "@sourcebot/shared"
|
||||||
|
|
||||||
export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
@ -47,6 +49,8 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const repoMetadata = repoMetadataSchema.parse(repo.metadata);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -99,7 +103,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DisplayDate date={repo.createdAt} className="text-2xl font-semibold"/>
|
<DisplayDate date={repo.createdAt} className="text-2xl font-semibold" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -118,7 +122,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{repo.indexedAt ? <DisplayDate date={repo.indexedAt} className="text-2xl font-semibold"/> : "Never" }
|
{repo.indexedAt ? <DisplayDate date={repo.indexedAt} className="text-2xl font-semibold" /> : "Never"}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -137,15 +141,35 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{nextIndexAttempt ? <DisplayDate date={nextIndexAttempt} className="text-2xl font-semibold"/> : "-" }
|
{nextIndexAttempt ? <DisplayDate date={nextIndexAttempt} className="text-2xl font-semibold" /> : "-"}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{repoMetadata.indexedRevisions && (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle>Indexed Branches</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Branches that have been indexed for this repository. <Link href="https://docs.sourcebot.dev/docs/features/search/multi-branch-indexing" target="_blank" className="text-link hover:underline">Docs</Link></CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||||
|
<RepoBranchesTable
|
||||||
|
indexRevisions={repoMetadata.indexedRevisions}
|
||||||
|
repoWebUrl={repo.webUrl}
|
||||||
|
repoCodeHostType={repo.external_codeHostType}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Indexing Jobs</CardTitle>
|
<CardTitle>Indexing Jobs</CardTitle>
|
||||||
<CardDescription>History of all indexing and cleanup jobs for this repository</CardDescription>
|
<CardDescription>History of all indexing and cleanup jobs for this repository.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
|
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { CodeHostType, getCodeHostBrowseAtBranchUrl } from "@/lib/utils"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
type RepoBranchesTableProps = {
|
||||||
|
indexRevisions: string[];
|
||||||
|
repoWebUrl: string | null;
|
||||||
|
repoCodeHostType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepoBranchesTable = ({ indexRevisions, repoWebUrl, repoCodeHostType }: RepoBranchesTableProps) => {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const columns = React.useMemo<ColumnDef<string>[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "refName",
|
||||||
|
header: "Revision",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const refName = row.original;
|
||||||
|
const shortRefName = refName.replace(/^refs\/(heads|tags)\//, "");
|
||||||
|
|
||||||
|
const branchUrl = getCodeHostBrowseAtBranchUrl({
|
||||||
|
webUrl: repoWebUrl,
|
||||||
|
codeHostType: repoCodeHostType as CodeHostType,
|
||||||
|
branchName: refName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return branchUrl ? (
|
||||||
|
<Link
|
||||||
|
href={branchUrl}
|
||||||
|
className="font-mono text-sm text-link hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{shortRefName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm text-muted-foreground"
|
||||||
|
title="This revision is not indexed"
|
||||||
|
>
|
||||||
|
{shortRefName}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, [repoCodeHostType, repoWebUrl]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: indexRevisions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter branches..."
|
||||||
|
value={(table.getColumn("refName")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) => table.getColumn("refName")?.setFilterValue(event.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No branches found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -352,6 +352,38 @@ export const getCodeHostCommitUrl = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getCodeHostBrowseAtBranchUrl = ({
|
||||||
|
webUrl,
|
||||||
|
codeHostType,
|
||||||
|
branchName,
|
||||||
|
}: {
|
||||||
|
webUrl?: string | null,
|
||||||
|
codeHostType: CodeHostType,
|
||||||
|
branchName: string,
|
||||||
|
}) => {
|
||||||
|
if (!webUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (codeHostType) {
|
||||||
|
case 'github':
|
||||||
|
return `${webUrl}/tree/${branchName}`;
|
||||||
|
case 'gitlab':
|
||||||
|
return `${webUrl}/-/tree/${branchName}`;
|
||||||
|
case 'gitea':
|
||||||
|
return `${webUrl}/src/branch/${branchName}`;
|
||||||
|
case 'azuredevops':
|
||||||
|
return `${webUrl}?branch=${branchName}`;
|
||||||
|
case 'bitbucket-cloud':
|
||||||
|
return `${webUrl}?at=${branchName}`;
|
||||||
|
case 'bitbucket-server':
|
||||||
|
return `${webUrl}?at=${branchName}`;
|
||||||
|
case 'gerrit':
|
||||||
|
case 'generic-git-host':
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean => {
|
export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean => {
|
||||||
switch (codeHostType) {
|
switch (codeHostType) {
|
||||||
case "github":
|
case "github":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue