Skip to content

Commit 5ddd1c2

Browse files
committed
Add Azure DevOps support as alternative project source provider
- Add Microsoft Entra ID authentication via next-auth - Implement AzureDevOpsClient for REST API interactions - Add AzureDevOpsProjectDataSource and AzureDevOpsRepositoryDataSource - Create unified IBlobProvider interface for file content fetching - Support binary image files in blob API - Configure via PROJECT_SOURCE_PROVIDER env var (github or azure-devops)
1 parent 0d907a2 commit 5ddd1c2

21 files changed

+1032
-70
lines changed

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,26 @@ HIDDEN_REPOSITORIES=
1515
NEW_PROJECT_TEMPLATE_REPOSITORY=shapehq/starter-openapi
1616
PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES = 10
1717
PROXY_API_TIMEOUT_IN_SECONDS = 30
18+
19+
# Project Source Provider: "github" or "azure-devops" (default: github)
20+
PROJECT_SOURCE_PROVIDER=github
21+
22+
# GitHub Configuration (required if PROJECT_SOURCE_PROVIDER=github)
1823
GITHUB_WEBHOOK_SECRET=preshared secret also put in app configuration in GitHub
1924
GITHUB_WEBHOK_REPOSITORY_ALLOWLIST=
2025
GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST=
2126
GITHUB_CLIENT_ID=GitHub App client ID
2227
GITHUB_CLIENT_SECRET=GitHub App client secret
2328
GITHUB_APP_ID=123456
2429
GITHUB_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key - see README.md for more info
30+
31+
# Azure DevOps Configuration (required if PROJECT_SOURCE_PROVIDER=azure-devops)
32+
# Uses Microsoft Entra ID (Azure AD) for authentication
33+
AZURE_ENTRA_ID_CLIENT_ID=Microsoft Entra ID App Registration client ID
34+
AZURE_ENTRA_ID_CLIENT_SECRET=Microsoft Entra ID App Registration client secret
35+
AZURE_ENTRA_ID_TENANT_ID=Microsoft Entra ID tenant/directory ID
36+
AZURE_DEVOPS_ORGANIZATION=your-azure-devops-organization-name
37+
2538
ENCRYPTION_PUBLIC_KEY_BASE_64=base 64 encoded version of the public key
2639
ENCRYPTION_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key
2740
NEXT_PUBLIC_ENABLE_DIFF_SIDEBAR=true
Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,32 @@
11
import { NextRequest, NextResponse } from "next/server"
2-
import { session, userGitHubClient } from "@/composition"
2+
import { session, blobProvider } from "@/composition"
33
import { makeUnauthenticatedAPIErrorResponse } from "@/common"
44

5-
export async function GET(req: NextRequest, { params }: { params: Promise<{ owner: string; repository: string; path: string[] }> }) {
5+
export async function GET(
6+
req: NextRequest,
7+
{ params }: { params: Promise<{ owner: string; repository: string; path: string[] }> }
8+
) {
69
const isAuthenticated = await session.getIsAuthenticated()
710
if (!isAuthenticated) {
811
return makeUnauthenticatedAPIErrorResponse()
912
}
1013
const { path: paramsPath, owner, repository } = await params
1114
const path = paramsPath.join("/")
12-
const item = await userGitHubClient.getRepositoryContent({
13-
repositoryOwner: owner,
14-
repositoryName: repository,
15-
path: path,
16-
ref: req.nextUrl.searchParams.get("ref") ?? undefined
17-
})
18-
const url = new URL(item.downloadURL)
19-
const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/;
20-
const file = await fetch(url).then(r => r.blob())
15+
const ref = req.nextUrl.searchParams.get("ref") ?? "main"
16+
17+
const content = await blobProvider.getFileContent(owner, repository, path, ref)
18+
if (content === null) {
19+
return NextResponse.json({ error: `File not found: ${path}` }, { status: 404 })
20+
}
21+
2122
const headers = new Headers()
22-
if (new RegExp(imageRegex).exec(path)) {
23+
const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/
24+
if (imageRegex.test(path)) {
2325
const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days
24-
headers.set("Content-Type", "image/*");
26+
headers.set("Content-Type", "image/*")
2527
headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`)
2628
} else {
27-
headers.set("Content-Type", "text/plain");
29+
headers.set("Content-Type", "text/plain")
2830
}
29-
return new NextResponse(file, { status: 200, headers })
31+
return new NextResponse(content, { status: 200, headers })
3032
}

src/app/api/hooks/github/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server"
22
import { gitHubHookHandler } from "@/composition"
33

44
export const POST = async (req: NextRequest): Promise<NextResponse> => {
5+
if (!gitHubHookHandler) {
6+
return NextResponse.json(
7+
{ error: "GitHub webhooks not available" },
8+
{ status: 404 }
9+
)
10+
}
511
await gitHubHookHandler.handle(req)
612
return NextResponse.json({ status: "OK" })
713
}

src/app/auth/signin/page.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { Box, Button, Stack, Typography } from "@mui/material"
33
import { signIn } from "@/composition"
44
import { env } from "@/common"
55
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
6-
import { faGithub } from "@fortawesome/free-brands-svg-icons"
6+
import { faGithub, faMicrosoft } from "@fortawesome/free-brands-svg-icons"
77
import SignInTexts from "@/features/auth/view/SignInTexts"
88
import MessageLinkFooter from "@/common/ui/MessageLinkFooter"
99

1010
const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE")
1111
const HELP_URL = env.get("FRAMNA_DOCS_HELP_URL")
12+
const PROJECT_SOURCE_PROVIDER = env.get("PROJECT_SOURCE_PROVIDER") || "github"
1213

1314
// Force page to be rendered dynamically to ensure we read the correct values for the environment variables.
1415
export const dynamic = "force-dynamic"
@@ -74,7 +75,7 @@ const SignInColumn = () => {
7475
}}>
7576
{title}
7677
</Typography>
77-
<SignInWithGitHub />
78+
<SignInButton />
7879
</Stack>
7980
</Box>
8081
{HELP_URL && (
@@ -89,20 +90,25 @@ const SignInColumn = () => {
8990
)
9091
}
9192

92-
const SignInWithGitHub = () => {
93+
const SignInButton = () => {
94+
const isAzureDevOps = PROJECT_SOURCE_PROVIDER === "azure-devops"
95+
const providerId = isAzureDevOps ? "microsoft-entra-id" : "github"
96+
const providerName = isAzureDevOps ? "Microsoft" : "GitHub"
97+
const providerIcon = isAzureDevOps ? faMicrosoft : faGithub
98+
9399
return (
94100
<form
95101
action={async () => {
96102
"use server"
97-
await signIn("github", { redirectTo: "/" })
103+
await signIn(providerId, { redirectTo: "/" })
98104
}}
99105
>
100106
<Button variant="outlined" type="submit">
101107
<Stack direction="row" alignItems="center" spacing={1} padding={1}>
102-
<FontAwesomeIcon icon={faGithub} size="2xl" />
103-
<Typography variant="h6" sx={{ display: "flex" }}>
104-
Sign in with GitHub
105-
</Typography>
108+
<FontAwesomeIcon icon={providerIcon} size="2xl" />
109+
<Typography variant="h6" sx={{ display: "flex" }}>
110+
Sign in with {providerName}
111+
</Typography>
106112
</Stack>
107113
</Button>
108114
</form>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import IAzureDevOpsClient, {
2+
AzureDevOpsRepository,
3+
AzureDevOpsRef,
4+
AzureDevOpsItem
5+
} from "./IAzureDevOpsClient"
6+
import { AzureDevOpsError } from "./AzureDevOpsError"
7+
8+
interface IOAuthTokenDataSource {
9+
getOAuthToken(): Promise<{ accessToken: string }>
10+
}
11+
12+
type AzureDevOpsApiResponse<T> = {
13+
value: T[]
14+
count: number
15+
}
16+
17+
export default class AzureDevOpsClient implements IAzureDevOpsClient {
18+
private readonly organization: string
19+
private readonly oauthTokenDataSource: IOAuthTokenDataSource
20+
private readonly apiVersion = "7.1"
21+
22+
constructor(config: {
23+
organization: string
24+
oauthTokenDataSource: IOAuthTokenDataSource
25+
}) {
26+
this.organization = config.organization
27+
this.oauthTokenDataSource = config.oauthTokenDataSource
28+
}
29+
30+
private async fetch<T>(endpoint: string): Promise<T> {
31+
const oauthToken = await this.oauthTokenDataSource.getOAuthToken()
32+
const url = `https://dev.azure.com/${this.organization}${endpoint}`
33+
const separator = endpoint.includes("?") ? "&" : "?"
34+
const fullUrl = `${url}${separator}api-version=${this.apiVersion}`
35+
36+
const response = await fetch(fullUrl, {
37+
headers: {
38+
Authorization: `Bearer ${oauthToken.accessToken}`,
39+
Accept: "application/json"
40+
},
41+
// Don't follow redirects - Azure DevOps returns 302 for auth failures
42+
redirect: "manual"
43+
})
44+
45+
// Check for redirect (302) - Azure DevOps redirects to login on auth failure
46+
if (response.status === 302) {
47+
const location = response.headers.get("location") || ""
48+
// Check if redirecting to a sign-in page (auth error)
49+
const isAuthRedirect = location.includes("/_signin") || location.includes("/login")
50+
throw new AzureDevOpsError(
51+
`Azure DevOps API redirect (302) to: ${location}`,
52+
302,
53+
isAuthRedirect // only trigger token refresh for auth redirects
54+
)
55+
}
56+
57+
// Check for authentication errors (401/403)
58+
if (response.status === 401 || response.status === 403) {
59+
const text = await response.text()
60+
throw new AzureDevOpsError(
61+
`Azure DevOps API authentication error: ${response.status} ${response.statusText} - ${text.substring(0, 200)}`,
62+
response.status,
63+
true // isAuthError - trigger token refresh
64+
)
65+
}
66+
67+
if (!response.ok) {
68+
const text = await response.text()
69+
throw new AzureDevOpsError(
70+
`Azure DevOps API error: ${response.status} ${response.statusText} - ${text.substring(0, 200)}`,
71+
response.status,
72+
false
73+
)
74+
}
75+
76+
return await response.json() as T
77+
}
78+
79+
async getRepositories(): Promise<AzureDevOpsRepository[]> {
80+
const response = await this.fetch<AzureDevOpsApiResponse<AzureDevOpsRepository>>(
81+
"/_apis/git/repositories"
82+
)
83+
return response.value
84+
}
85+
86+
async getRefs(repositoryId: string): Promise<AzureDevOpsRef[]> {
87+
const response = await this.fetch<AzureDevOpsApiResponse<AzureDevOpsRef>>(
88+
`/_apis/git/repositories/${repositoryId}/refs`
89+
)
90+
return response.value
91+
}
92+
93+
async getItems(repositoryId: string, scopePath: string, version: string): Promise<AzureDevOpsItem[]> {
94+
try {
95+
const response = await this.fetch<AzureDevOpsApiResponse<AzureDevOpsItem>>(
96+
`/_apis/git/repositories/${repositoryId}/items?scopePath=${encodeURIComponent(scopePath)}&recursionLevel=OneLevel&versionDescriptor.version=${encodeURIComponent(version)}`
97+
)
98+
return response.value
99+
} catch {
100+
return []
101+
}
102+
}
103+
104+
private isImageFile(path: string): boolean {
105+
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".ico"]
106+
const lowerPath = path.toLowerCase()
107+
return imageExtensions.some(ext => lowerPath.endsWith(ext))
108+
}
109+
110+
async getFileContent(repositoryId: string, path: string, version: string): Promise<string | ArrayBuffer | null> {
111+
try {
112+
const oauthToken = await this.oauthTokenDataSource.getOAuthToken()
113+
const url = `https://dev.azure.com/${this.organization}/_apis/git/repositories/${repositoryId}/items?path=${encodeURIComponent(path)}&versionDescriptor.version=${encodeURIComponent(version)}&api-version=${this.apiVersion}`
114+
115+
const isImage = this.isImageFile(path)
116+
const response = await fetch(url, {
117+
headers: {
118+
Authorization: `Bearer ${oauthToken.accessToken}`,
119+
Accept: isImage ? "application/octet-stream" : "text/plain"
120+
}
121+
})
122+
123+
if (!response.ok) {
124+
return null
125+
}
126+
127+
if (isImage) {
128+
return await response.arrayBuffer()
129+
}
130+
return await response.text()
131+
} catch {
132+
return null
133+
}
134+
}
135+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Error thrown by Azure DevOps API client when requests fail.
3+
* Includes HTTP status code for proper error handling.
4+
*/
5+
export class AzureDevOpsError extends Error {
6+
readonly status: number
7+
readonly isAuthError: boolean
8+
9+
constructor(message: string, status: number, isAuthError: boolean) {
10+
super(message)
11+
this.name = "AzureDevOpsError"
12+
this.status = status
13+
this.isAuthError = isAuthError
14+
}
15+
}
16+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export type AzureDevOpsRepository = {
2+
readonly id: string
3+
readonly name: string
4+
readonly defaultBranch?: string
5+
readonly webUrl: string
6+
readonly project: {
7+
readonly id: string
8+
readonly name: string
9+
}
10+
}
11+
12+
export type AzureDevOpsRef = {
13+
readonly name: string // e.g., "refs/heads/main"
14+
readonly objectId: string
15+
}
16+
17+
export type AzureDevOpsItem = {
18+
readonly path: string
19+
readonly gitObjectType: "blob" | "tree"
20+
}
21+
22+
export default interface IAzureDevOpsClient {
23+
getRepositories(): Promise<AzureDevOpsRepository[]>
24+
getRefs(repositoryId: string): Promise<AzureDevOpsRef[]>
25+
getItems(repositoryId: string, scopePath: string, version: string): Promise<AzureDevOpsItem[]>
26+
getFileContent(repositoryId: string, path: string, version: string): Promise<string | ArrayBuffer | null>
27+
}

0 commit comments

Comments
 (0)