Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.sprintstart.sprintstartbackend.connectors.github.external

import java.util.UUID

/** Whether an owner name is an organisation or a personal account, which GitHub lists differently. */
enum class GithubOwnerKind {
ORGANISATION,
USER,
}

/** A repository somebody names by its two parts. */
data class GithubRepositoryRef(
val owner: String,
val name: String,
)

/**
* One repository GitHub lists for an owner, and whether SprintStart already has it.
*
* @property alreadyConnected Whether some project already has this repository connected.
* @property enabled Whether that connection is enabled for ingestion, or null when it is not connected.
*/
data class GithubDiscoveredRepositoryDto(
val name: String,
val isPrivate: Boolean,
val url: String,
val alreadyConnected: Boolean,
val enabled: Boolean?,
)

/**
* How connecting one repository went.
*
* @property failure Why it did not connect, in words for the person who asked; null when it did.
* @property reused True when the repository was already connected for another project, so the project
* was linked to it and nothing was fetched again.
*/
data class GithubConnectResultDto(
val repository: GithubRepositoryRef,
val reused: Boolean,
val failure: String?,
)

/**
* What other modules may do with the GitHub connector on somebody's behalf: read their token names,
* look at what GitHub lists, connect repositories, link and unlink them, and start a sync.
*
* Everything takes the caller's `authId` because GitHub access here is always the caller's own: the
* tokens are theirs, and whether they may see a repository is asked of GitHub with those tokens. Nothing
* here returns or accepts a token value — only the names people gave their tokens.
*
* Failures are [org.springframework.web.server.ResponseStatusException]s with a message that is safe to
* show the person, so a caller need not know the connector's own exception types.
*/
interface GithubSourcesApi {
/** The names of the personal access tokens [authId] has stored. Never the tokens. */
fun getTokenNames(authId: String): List<String>

/**
* What GitHub lists for [owner], read with the token [tokenName] of [authId].
*
* @throws org.springframework.web.server.ResponseStatusException 404 when that token does not exist
* for the caller.
*/
suspend fun discoverRepositories(
authId: String,
kind: GithubOwnerKind,
owner: String,
tokenName: String,
page: Int,
pageSize: Int,
): List<GithubDiscoveredRepositoryDto>

/**
* Connects each repository to [projectId], one at a time and each on its own.
*
* A repository that is already connected for another project is linked instead of fetched again,
* after checking that the caller can see it. One repository failing does not stop the others, so
* the result says how each went. A new repository starts fetching its files, commits, issues and
* pull requests in the background.
*/
suspend fun connectRepositories(
authId: String,
projectId: UUID,
tokenName: String,
repositories: List<GithubRepositoryRef>,
): List<GithubConnectResultDto>

/**
* Links an already-connected repository to [projectId], after checking that the caller can see it
* on GitHub with one of their own tokens.
*
* @return The projects the repository is linked to afterwards.
* @throws org.springframework.web.server.ResponseStatusException 404 when it is not connected or the
* caller cannot see it — the same answer for both.
*/
suspend fun linkRepository(authId: String, projectId: UUID, repositoryId: UUID): Set<UUID>

/**
* Takes [projectId] off a repository. The connection and what was fetched stay; only this project's
* membership goes.
*
* @return The projects the repository is linked to afterwards.
*/
fun unlinkRepository(authId: String, projectId: UUID, repositoryId: UUID): Set<UUID>

/**
* Starts fetching a connected repository's latest state in the background.
*
* Carries no project: it updates the one connection every linked project shares.
*
* @return The id the update reports its progress under.
* @throws org.springframework.web.server.ResponseStatusException 404 when it is not connected, 400
* when its first fetch has not finished.
*/
fun syncRepository(repository: GithubRepositoryRef): UUID
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.sprintstart.sprintstartbackend.connectors.github.service

import com.sprintstart.sprintstartbackend.connectors.github.external.GithubConnectResultDto
import com.sprintstart.sprintstartbackend.connectors.github.external.GithubDiscoveredRepositoryDto
import com.sprintstart.sprintstartbackend.connectors.github.external.GithubOwnerKind
import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryRef
import com.sprintstart.sprintstartbackend.connectors.github.external.GithubSourcesApi
import com.sprintstart.sprintstartbackend.connectors.github.models.api.requests.ConnectRepositoryRequest
import com.sprintstart.sprintstartbackend.connectors.github.models.api.requests.DiscoverRepositoriesRequest
import com.sprintstart.sprintstartbackend.connectors.github.models.api.requests.UpdateRepositoryRequest
import com.sprintstart.sprintstartbackend.connectors.github.models.exceptions.GithubUserPatNotFoundException
import com.sprintstart.sprintstartbackend.connectors.github.models.exceptions.ProjectAccessDeniedException
import com.sprintstart.sprintstartbackend.connectors.github.models.exceptions.RepositoryNotConnectedException
import com.sprintstart.sprintstartbackend.connectors.github.models.exceptions.RepositoryNotFoundException
import com.sprintstart.sprintstartbackend.connectors.github.models.exceptions.RepositoryNotInitializedException
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
import kotlin.coroutines.cancellation.CancellationException

/**
* The GitHub connector as other modules may use it: the same services the REST controllers call, with the
* connector's own exceptions turned into ones that say something to a person.
*
* Deliberately thin. The rules — who may see a repository, what a link does to artifacts, what a sync
* fetches — live in the services behind this, and this does not restate them.
*/
@Service
class GithubSourcesApiService(
private val githubUserService: GithubUserService,
private val githubConnectorService: GithubConnectorService,
private val visibilityService: GithubRepositoryVisibilityService,
private val githubRepositoryProjectService: GithubRepositoryProjectService,
private val githubUpdatesService: GithubUpdatesService,
) : GithubSourcesApi {
private val logger = LoggerFactory.getLogger(javaClass)

override fun getTokenNames(authId: String): List<String> = githubUserService.getAllPATNames(authId)

override suspend fun discoverRepositories(
authId: String,
kind: GithubOwnerKind,
owner: String,
tokenName: String,
page: Int,
pageSize: Int,
): List<GithubDiscoveredRepositoryDto> {
val request = DiscoverRepositoriesRequest(owner, authId, tokenName, page, pageSize)
val found = translated {
when (kind) {
GithubOwnerKind.ORGANISATION -> githubConnectorService.discoverRepositoriesOfOrg(request)
GithubOwnerKind.USER -> githubConnectorService.discoverRepositoriesOfUser(request)
}
}
return found.repositories.map {
GithubDiscoveredRepositoryDto(it.name, it.isPrivate, it.url, it.alreadyConnected, it.isEnabled)
}
}

override suspend fun connectRepositories(
authId: String,
projectId: UUID,
tokenName: String,
repositories: List<GithubRepositoryRef>,
): List<GithubConnectResultDto> =
repositories.map { repository ->
try {
val outcome = githubConnectorService.connectRepositoryIfNecessary(
authId,
ConnectRepositoryRequest(repository.owner, repository.name, tokenName, projectId),
)
GithubConnectResultDto(repository, outcome.wasReused, failure = null)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
GithubConnectResultDto(repository, reused = false, failure = reasonOf(e, repository))
}
}

override suspend fun linkRepository(authId: String, projectId: UUID, repositoryId: UUID): Set<UUID> {
// Checked before the transactional link, as the REST endpoint does, because it suspends.
translated { visibilityService.requireCallerCanSeeConnection(authId, repositoryId) }
return translated { githubRepositoryProjectService.addProjectToRepository(authId, repositoryId, projectId) }
}

override fun unlinkRepository(authId: String, projectId: UUID, repositoryId: UUID): Set<UUID> =
translatedBlocking {
githubRepositoryProjectService.removeProjectFromRepository(
authId,
repositoryId,
projectId,
)
}

override fun syncRepository(repository: GithubRepositoryRef): UUID =
translatedBlocking {
githubUpdatesService
.updateRepository(UpdateRepositoryRequest(repository.owner, repository.name), true)
.transactionId
}

private suspend fun <T> translated(block: suspend () -> T): T =
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw asStatusException(e)
}

private fun <T> translatedBlocking(block: () -> T): T =
try {
block()
} catch (e: Exception) {
throw asStatusException(e)
}

private fun asStatusException(e: Exception): Exception =
when (e) {
is ResponseStatusException -> e
is GithubUserPatNotFoundException ->
ResponseStatusException(HttpStatus.NOT_FOUND, "There is no GitHub token named “${e.name}”.")
is RepositoryNotFoundException -> ResponseStatusException(HttpStatus.NOT_FOUND, e.message)
is RepositoryNotConnectedException -> ResponseStatusException(HttpStatus.NOT_FOUND, e.message)
is ProjectAccessDeniedException -> ResponseStatusException(HttpStatus.FORBIDDEN, e.message)
is RepositoryNotInitializedException ->
ResponseStatusException(HttpStatus.BAD_REQUEST, "Its first fetch has not finished yet.")
else -> {
logger.warn("GitHub connector call failed", e)
ResponseStatusException(HttpStatus.BAD_GATEWAY, "GitHub or the connector could not complete that.")
}
}

private fun reasonOf(e: Exception, repository: GithubRepositoryRef): String =
(asStatusException(e) as? ResponseStatusException)?.reason
?: "${repository.owner}/${repository.name} could not be connected."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.sprintstart.sprintstartbackend.onboarding.service

import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryApi
import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryRef
import com.sprintstart.sprintstartbackend.connectors.github.external.GithubSourcesApi
import org.springframework.stereotype.Component
import java.util.UUID

/**
* A repository SprintStart has connected, and what it means for the turn's project.
*
* @property otherProjects How many other projects share it. A repository is one connection however many
* projects are linked to it, so linking, unlinking and syncing all reach past the turn's project.
*/
data class ConnectedRepository(
val id: UUID,
val ref: GithubRepositoryRef,
val linkedHere: Boolean,
val otherProjects: Int,
)

/**
* Which GitHub repositories a project's manager may act on, and the credential check that goes with them.
*
* Connecting a repository to a project is how its material reaches that project, and a sync or an unlink
* reaches every project sharing the connection. So a repository is in scope for a sync or an unlink only
* when it is linked to the turn's project; for a link it must exist, and whether the manager may see it on
* GitHub is asked of GitHub itself when they confirm.
*
* Credentials are only ever names. [tokenProblem] is the one place a token name is judged, and it never
* repeats what it was given: a person who pasted a token into the conversation must not have it echoed
* into a preview or a stored proposal.
*/
@Component
class GithubSourcesScope(
private val githubRepositoryApi: GithubRepositoryApi,
private val githubSourcesApi: GithubSourcesApi,
) {
/** The connected repository [owner]/[name] names, or null when nothing is connected under it. */
fun find(owner: String, name: String, projectId: UUID): ConnectedRepository? {
val id = githubRepositoryApi.getRepositoryIdByOwnerAndName(owner, name) ?: return null
val projects = runCatching { githubRepositoryApi.getRepositoryProjectIdsById(id) }
.getOrElse { if (it is NoSuchElementException) return null else throw it }
return ConnectedRepository(
id = id,
ref = GithubRepositoryRef(owner, name),
linkedHere = projectId in projects,
otherProjects = projects.count { it != projectId },
)
}

/** Why [tokenName] cannot be used for [authId], or null when it names one of their own tokens. */
fun tokenProblem(authId: String, tokenName: String): String? {
if (tokenName.isEmpty()) return "A token name is needed. Call list_my_credential_names for the ones there are."
if (TOKEN_SHAPE.containsMatchIn(tokenName)) {
return "That looks like a token itself rather than the name of one. Never paste a token here: tell the " +
"manager to store it under a name on the settings page, and use that name."
}
return if (tokenName in githubSourcesApi.getTokenNames(authId)) {
null
} else {
"You have no GitHub token with that name. Call list_my_credential_names for the ones you do have."
}
}

/** "owner/name" for a preview. */
fun label(ref: GithubRepositoryRef): String = "${ref.owner}/${ref.name}"

/** The sentences a preview adds when other projects share the connection, or empty. */
fun sharedNote(repository: ConnectedRepository, consequence: String): String =
when (repository.otherProjects) {
0 -> ""
1 -> "One other project shares this repository. $consequence"
else -> "${repository.otherProjects} other projects share this repository. $consequence"
}

companion object {
/** The prefixes GitHub gives its tokens: `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` and `github_pat_`. */
private val TOKEN_SHAPE = Regex("^(gh[pousr]_|github_pat_)", RegexOption.IGNORE_CASE)
}
}
Loading
Loading