diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/external/GithubSourcesApi.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/external/GithubSourcesApi.kt new file mode 100644 index 00000000..b1140d61 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/external/GithubSourcesApi.kt @@ -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 + + /** + * 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 + + /** + * 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, + ): List + + /** + * 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 + + /** + * 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 + + /** + * 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 +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiService.kt new file mode 100644 index 00000000..1fa618d2 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiService.kt @@ -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 = githubUserService.getAllPATNames(authId) + + override suspend fun discoverRepositories( + authId: String, + kind: GithubOwnerKind, + owner: String, + tokenName: String, + page: Int, + pageSize: Int, + ): List { + 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, + ): List = + 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 { + // 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 = + 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 translated(block: suspend () -> T): T = + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw asStatusException(e) + } + + private fun 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." +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScope.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScope.kt new file mode 100644 index 00000000..d773fb81 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScope.kt @@ -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) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActions.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActions.kt new file mode 100644 index 00000000..5cb27056 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActions.kt @@ -0,0 +1,373 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryRef +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubSourcesApi +import com.sprintstart.sprintstartbackend.onboarding.external.enums.BuddyProposalRisk +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +/* + * The GitHub actions of team mode's sources area. + * + * A repository is one connection however many projects use it, so linking, unlinking and syncing reach + * past the turn's project. Each action therefore resolves its repository through GithubSourcesScope — it + * must be linked to the turn's project to be unlinked or synced — and says in its preview when other + * projects share it. + * + * Credentials are names. No action takes a token, and a token name that looks like one is refused without + * being repeated. + */ + +private const val MAX_REPOSITORIES = 20 + +private const val INGESTION_PAGE = "the data-ingestion page" + +private const val NAME_A_REPOSITORY = "Give the repository as owner and name." + +private const val NOT_LINKED_HERE = + "That repository is not linked to this project. Call list_project_sources for the ones that are." + +private const val WHAT_A_LINK_REACHES = + "Its files, commits, issues and pull requests reach this project's hires and the buddy, and its issues " + + "can be starter work here." + +private fun repositoryArguments() = + toolFields( + ToolField("owner", "The repository owner, as in owner/name."), + ToolField("name", "The repository name, as in owner/name."), + required = listOf("owner", "name"), + ) + +/** The repository a tool call names, or null when it names none. */ +private fun BuddyToolCallDto.repository(): GithubRepositoryRef? = arguments.repositoryOrNull() + +private fun JsonObject.repositoryOrNull(): GithubRepositoryRef? = + GithubRepositoryRef(text("owner"), text("name")).takeIf { it.owner.isNotEmpty() && it.name.isNotEmpty() } + +/** The repository a stored proposal names; it was validated when it was drafted. */ +private fun JsonObject.repository(): GithubRepositoryRef = requireNotNull(repositoryOrNull()) + +private fun GithubRepositoryRef.stored(): JsonObject = + buildJsonObject { + put("owner", owner) + put("name", name) + } + +private fun plural(count: Int, singular: String, plural: String = "${singular}s") = + "$count ${if (count == 1) singular else plural}" + +/** Offers to connect repositories to the project, each on its own. */ +@Component +class ConnectRepositoriesAction( + private val scope: GithubSourcesScope, + private val githubSourcesApi: GithubSourcesApi, +) : TeamActionHandler { + override val area = TeamArea.SOURCES + override val risk = BuddyProposalRisk.BULK + override val spec = BuddyToolSpecDto( + name = "connect_repositories", + description = "Offer to connect GitHub repositories to this project, using one of the manager's stored " + + "tokens by NAME from list_my_credential_names. Never pass a token, and refuse one pasted into the " + + "conversation. A new repository starts fetching its code, commits, issues and pull requests in the " + + "background; one already connected for another project is only linked. Use discover_repositories " + + "first so the names are real. This does NOT connect anything by itself; the manager confirms.", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("token_name") { + put("type", "string") + put("description", "The NAME of one of the manager's stored tokens.") + } + putJsonObject("repositories") { + put("type", "array") + put("description", "Up to $MAX_REPOSITORIES repositories.") + put("items", repositoryArguments()) + } + } + putJsonArray("required") { + add("token_name") + add("repositories") + } + }, + ) + + override fun draft(call: BuddyToolCallDto, context: TeamToolContext): TeamActionDraft { + val token = call.textArgument("token_name") + scope.tokenProblem(context.authId, token)?.let { return TeamActionDraft.Refused(it) } + + val asked = call.arguments + .objectArray("repositories") + .mapNotNull { it.repositoryOrNull() } + .distinct() + if (asked.isEmpty()) return TeamActionDraft.Refused("No repository was given. $NAME_A_REPOSITORY") + if (asked.size > MAX_REPOSITORIES) { + return TeamActionDraft.Refused("At most $MAX_REPOSITORIES at a time; offer the rest afterwards.") + } + + val states = asked.map { it to scope.find(it.owner, it.name, context.projectId) } + val toConnect = states.filter { it.second?.linkedHere != true } + val alreadyHere = states.filter { it.second?.linkedHere == true }.map { it.first } + if (toConnect.isEmpty()) { + return TeamActionDraft.Refused("Every one of those is already connected to this project.") + } + + return TeamActionDraft.Proposed( + params = buildJsonObject { + put("token_name", token) + putJsonArray("repositories") { toConnect.forEach { add(it.first.stored()) } } + }, + label = labelFor(toConnect.map { it.first }), + preview = previewOf(token, toConnect, alreadyHere), + ) + } + + private fun labelFor(repositories: List): String = + if (repositories.size == 1) { + "Connect ${scope.label(repositories.single())}" + } else { + "Connect ${repositories.size} repositories" + } + + private fun previewOf( + token: String, + toConnect: List>, + alreadyHere: List, + ): String = + buildString { + appendLine("Connect to this project, using your token “$token”:") + toConnect.forEach { (ref, existing) -> + val effect = if (existing == null) NEW_REPOSITORY else SHARED_REPOSITORY + appendLine("- ${scope.label(ref)} — $effect") + } + if (alreadyHere.isNotEmpty()) { + appendLine("Left out, already on this project: ${alreadyHere.joinToString(", ") { scope.label(it) }}.") + } + appendLine() + appendLine(ASKED_OF_GITHUB) + append("A new repository keeps using your token “$token” for its nightly updates.") + }.trim() + + override fun recheck(params: JsonObject, context: TeamToolContext): String? { + scope.tokenProblem(context.authId, params.text("token_name"))?.let { return it } + val allLinkedNow = params + .objectArray("repositories") + .map { it.repository() } + .all { scope.find(it.owner, it.name, context.projectId)?.linkedHere == true } + return ALL_CONNECTED_SINCE.takeIf { allLinkedNow } + } + + override suspend fun perform(params: JsonObject, context: TeamToolContext): String { + val results = githubSourcesApi.connectRepositories( + context.authId, + context.projectId, + params.text("token_name"), + params.objectArray("repositories").map { it.repository() }, + ) + val failed = results.filter { it.failure != null } + val failures = failed.joinToString("; ") { "${scope.label(it.repository)} — ${it.failure}" } + if (failed.size == results.size) { + throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "None could be connected: $failures") + } + + val connected = results.filter { it.failure == null } + return buildString { + append("Connected ${connected.joinToString(", ") { scope.label(it.repository) }}. ") + if (connected.any { !it.reused }) append("New ones fetch in the background; follow it on $INGESTION_PAGE. ") + if (failed.isNotEmpty()) append("Not connected: $failures.") + }.trim() + } + + private companion object { + const val NEW_REPOSITORY = "new: starts fetching its files, commits, issues and pull requests" + const val SHARED_REPOSITORY = "already connected for another project: linked here, nothing fetched again" + const val ASKED_OF_GITHUB = + "GitHub is asked whether your token can see each one when you confirm; one it cannot is skipped and " + + "reported." + const val ALL_CONNECTED_SINCE = + "Every one of those has been connected to this project since, so nothing was changed." + } +} + +/** Offers to link an already-connected repository to the project. */ +@Component +class LinkRepositoryAction( + private val scope: GithubSourcesScope, + private val githubSourcesApi: GithubSourcesApi, +) : TeamActionHandler { + override val area = TeamArea.SOURCES + override val risk = BuddyProposalRisk.STANDARD + override val spec = BuddyToolSpecDto( + name = "link_repository", + description = "Offer to link a repository that is already connected for another project to this one, " + + "without fetching anything again. Its material then reaches this project. If it is not connected " + + "yet, offer connect_repositories instead. This does NOT link anything by itself; the manager " + + "confirms.", + parameters = repositoryArguments(), + ) + + override fun draft(call: BuddyToolCallDto, context: TeamToolContext): TeamActionDraft { + val ref = call.repository() ?: return TeamActionDraft.Refused(NAME_A_REPOSITORY) + val repository = scope.find(ref.owner, ref.name, context.projectId) + ?: return TeamActionDraft.Refused( + "${scope.label(ref)} is not connected to SprintStart. Offer connect_repositories with one of " + + "the manager's tokens.", + ) + if (repository.linkedHere) { + return TeamActionDraft.Refused("${scope.label(ref)} is already linked to this project.") + } + + return TeamActionDraft.Proposed( + params = ref.stored(), + label = "Link ${scope.label(ref)}", + preview = buildString { + appendLine("Link ${scope.label(ref)} to this project.") + appendLine() + appendLine(WHAT_A_LINK_REACHES) + appendLine("Nothing is fetched again; it is the connection the other projects already use.") + append(ASKED_OF_GITHUB) + scope + .sharedNote(repository, "Nothing changes for them.") + .takeIf { it.isNotEmpty() } + ?.let { append("\n\n$it") } + }.trim(), + ) + } + + override fun recheck(params: JsonObject, context: TeamToolContext): String? { + val ref = params.repository() + val repository = scope.find(ref.owner, ref.name, context.projectId) + ?: return "${scope.label(ref)} is no longer connected, so nothing was changed." + val linkedSince = "${scope.label(ref)} was linked to this project since, so nothing was changed." + return linkedSince.takeIf { repository.linkedHere } + } + + override suspend fun perform(params: JsonObject, context: TeamToolContext): String { + val ref = params.repository() + val repository = scope.find(ref.owner, ref.name, context.projectId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "${scope.label(ref)} is no longer connected.") + githubSourcesApi.linkRepository(context.authId, context.projectId, repository.id) + return "Linked. ${scope.label(ref)} is on this project now." + } + + private companion object { + const val ASKED_OF_GITHUB = + "GitHub is asked whether you can see it with one of your own tokens when you confirm. If you cannot, " + + "it is refused as not found." + } +} + +/** Offers to take the project off a repository, leaving the connection for whoever else uses it. */ +@Component +class UnlinkRepositoryAction( + private val scope: GithubSourcesScope, + private val githubSourcesApi: GithubSourcesApi, +) : TeamActionHandler { + override val area = TeamArea.SOURCES + override val risk = BuddyProposalRisk.DESTRUCTIVE + override val spec = BuddyToolSpecDto( + name = "unlink_repository", + description = "Offer to take a repository off this project. Only this project's link goes: the " + + "connection stays for any other project using it. Read list_project_sources first. This does NOT " + + "unlink anything by itself; the manager confirms.", + parameters = repositoryArguments(), + ) + + override fun draft(call: BuddyToolCallDto, context: TeamToolContext): TeamActionDraft { + val ref = call.repository() ?: return TeamActionDraft.Refused(NAME_A_REPOSITORY) + val repository = scope.find(ref.owner, ref.name, context.projectId)?.takeIf { it.linkedHere } + ?: return TeamActionDraft.Refused(NOT_LINKED_HERE) + + return TeamActionDraft.Proposed( + params = ref.stored(), + label = "Unlink ${scope.label(ref)}", + preview = buildString { + appendLine("Take ${scope.label(ref)} off this project.") + appendLine() + appendLine( + "Its files, commits, issues and pull requests stop reaching this project's hires and the " + + "buddy, and its issues leave this project's starter-work pool.", + ) + append(whatRemains(repository.otherProjects)) + }.trim(), + ) + } + + private fun whatRemains(otherProjects: Int): String = + when (otherProjects) { + 0 -> "Nothing else uses it, so it stays connected but reaches no project." + 1 -> "The connection stays, and one other project keeps it." + else -> "The connection stays, and ${plural(otherProjects, "other project")} keep it." + } + + override fun recheck(params: JsonObject, context: TeamToolContext): String? { + val ref = params.repository() + val goneSince = "${scope.label(ref)} is no longer linked to this project, so nothing was changed." + return goneSince.takeIf { scope.find(ref.owner, ref.name, context.projectId)?.linkedHere != true } + } + + override suspend fun perform(params: JsonObject, context: TeamToolContext): String { + val ref = params.repository() + val repository = scope.find(ref.owner, ref.name, context.projectId)?.takeIf { it.linkedHere } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, NOT_LINKED_HERE) + githubSourcesApi.unlinkRepository(context.authId, context.projectId, repository.id) + return "Unlinked. ${scope.label(ref)} is off this project." + } +} + +/** Offers to fetch a repository's latest state now. */ +@Component +class SyncRepositoryAction( + private val scope: GithubSourcesScope, + private val githubSourcesApi: GithubSourcesApi, +) : TeamActionHandler { + override val area = TeamArea.SOURCES + override val risk = BuddyProposalRisk.BULK + override val spec = BuddyToolSpecDto( + name = "sync_repository", + description = "Offer to fetch the latest files, commits, issues and pull requests of a repository " + + "linked to this project, now instead of at the nightly run. It runs in the background. Only a " + + "repository linked to this project can be synced. Read list_project_sources first. This does NOT " + + "start anything by itself; the manager confirms.", + parameters = repositoryArguments(), + ) + + override fun draft(call: BuddyToolCallDto, context: TeamToolContext): TeamActionDraft { + val ref = call.repository() ?: return TeamActionDraft.Refused(NAME_A_REPOSITORY) + val repository = scope.find(ref.owner, ref.name, context.projectId)?.takeIf { it.linkedHere } + ?: return TeamActionDraft.Refused("$NOT_LINKED_HERE It cannot be synced from here otherwise.") + + return TeamActionDraft.Proposed( + params = ref.stored(), + label = "Sync ${scope.label(ref)}", + preview = buildString { + appendLine("Fetch the latest files, commits, issues and pull requests of ${scope.label(ref)} now.") + append("It runs in the background and you can follow it on $INGESTION_PAGE.") + scope + .sharedNote(repository, "They get the update too.") + .takeIf { it.isNotEmpty() } + ?.let { append("\n\n$it") } + }.trim(), + ) + } + + override fun recheck(params: JsonObject, context: TeamToolContext): String? { + val ref = params.repository() + val goneSince = "${scope.label(ref)} is no longer linked to this project, so nothing was changed." + return goneSince.takeIf { scope.find(ref.owner, ref.name, context.projectId)?.linkedHere != true } + } + + override suspend fun perform(params: JsonObject, context: TeamToolContext): String { + val ref = params.repository() + githubSourcesApi.syncRepository(ref) + return "Started. ${scope.label(ref)} is fetching in the background; follow it on $INGESTION_PAGE." + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamTools.kt new file mode 100644 index 00000000..bdb065e7 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamTools.kt @@ -0,0 +1,179 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubOwnerKind +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryApi +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubSourceInstanceDto +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubSourcesApi +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException +import java.time.ZoneOffset +import java.util.UUID + +private const val DEFAULT_PAGE_SIZE = 20 + +private val OWNER_KINDS = mapOf("organisation" to GithubOwnerKind.ORGANISATION, "user" to GithubOwnerKind.USER) + +private fun GithubSourceInstanceDto.said(): String = + "$owner/$name — ${status.lowercase().replace('_', ' ')}, ${if (enabled) "enabled" else "disabled"}" + + (lastCommitsSyncAt?.let { ", commits last synced ${it.atZone(ZoneOffset.UTC).toLocalDate()}" } ?: "") + +/** + * The read tools of team mode's sources area: which credentials the manager has stored, what GitHub lists + * for an owner, and what this project has connected. + * + * Credentials are read as names and nothing else. Nothing here returns, or accepts, a token. + * + * Only GitHub is behind this so far; Jira, Confluence and uploads join the same area, and these reads, + * when they get their tools. + */ +@Component +class SourcesTeamTools( + private val githubSourcesApi: GithubSourcesApi, + private val githubRepositoryApi: GithubRepositoryApi, + private val scope: GithubSourcesScope, +) : TeamAreaTools { + override val area = TeamArea.SOURCES + + override fun toolSpecs(): List = listOf( + CREDENTIAL_NAMES_SPEC, + DISCOVER_SPEC, + PROJECT_SOURCES_SPEC, + ) + + override fun handles(toolName: String): Boolean = toolSpecs().any { it.name == toolName } + + override fun execute(call: BuddyToolCallDto, context: TeamToolContext): String = + when (call.name) { + LIST_MY_CREDENTIAL_NAMES -> credentialNames(context.authId) + DISCOVER_REPOSITORIES -> discover(call, context) + LIST_PROJECT_SOURCES -> projectSources(context.projectId) + else -> "Unknown tool: ${call.name}." + } + + private fun credentialNames(authId: String): String { + val names = githubSourcesApi.getTokenNames(authId).sorted() + if (names.isEmpty()) { + return "The manager has stored no GitHub token yet. One is added on the settings page, not here — " + + "point them to it, and never ask them to paste a token into the conversation." + } + return "The manager's stored GitHub tokens (names only):\n" + names.joinToString("\n") { "- $it" } + + "\nPass one of these names as token_name. Tokens are added and removed on the settings page." + } + + /** + * What GitHub lists for an owner, read with one of the manager's own tokens. + * + * A network call, and this tool interface is not suspending, so it blocks the request thread for + * as long as GitHub takes. That is what a manager waiting for an answer is doing anyway. + */ + private fun discover(call: BuddyToolCallDto, context: TeamToolContext): String { + val kind = OWNER_KINDS[call.textArgument("kind").lowercase()] + ?: return "kind must be organisation or user: the two are listed differently." + val owner = call.textArgument("owner") + if (owner.isEmpty()) return "An owner is needed: the organisation or user whose repositories to list." + val token = call.textArgument("token_name") + scope.tokenProblem(context.authId, token)?.let { return it } + val page = call.textArgument("page").toIntOrNull()?.takeIf { it >= 0 } ?: 0 + + val found = try { + runBlocking { + githubSourcesApi.discoverRepositories( + context.authId, + kind, + owner, + token, + page, + DEFAULT_PAGE_SIZE, + ) + } + } catch (e: ResponseStatusException) { + return e.reason ?: "GitHub could not list that." + } + if (found.isEmpty()) { + val none = if (page == + 0 + ) { + "GitHub lists no repositories for $owner with that token." + } else { + "No more repositories." + } + return none + } + return buildString { + appendLine("Repositories of $owner (page $page, up to $DEFAULT_PAGE_SIZE per page):") + found.forEach { + append("- ${it.name}${if (it.isPrivate) " (private)" else ""}") + if (it.alreadyConnected) append(" — already connected${if (it.enabled == false) ", disabled" else ""}") + appendLine() + } + if (found.size == DEFAULT_PAGE_SIZE) append("There may be more: ask again with page ${page + 1}.") + }.trim() + } + + private fun projectSources(projectId: UUID): String { + val repositories = githubRepositoryApi.getSourceInstances(projectId) + if (repositories.isEmpty()) return "This project has no GitHub repository connected." + return buildString { + appendLine("GitHub repositories connected to this project:") + repositories.forEach { repository -> + append("- ${repository.said()}") + val shared = scope.find(repository.owner, repository.name, projectId)?.otherProjects ?: 0 + if (shared > 0) append(" — shared with $shared other project${if (shared == 1) "" else "s"}") + appendLine() + } + }.trim() + } + + companion object { + const val LIST_MY_CREDENTIAL_NAMES = "list_my_credential_names" + const val DISCOVER_REPOSITORIES = "discover_repositories" + const val LIST_PROJECT_SOURCES = "list_project_sources" + + private fun noArgs() = + buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { }) + } + + private val CREDENTIAL_NAMES_SPEC = BuddyToolSpecDto( + name = LIST_MY_CREDENTIAL_NAMES, + description = "The names of the GitHub tokens the manager has stored. Names only: you never see a " + + "token and must never ask for one. If the manager pastes a token into the conversation, do " + + "not use it and do not repeat it; tell them to store it on the settings page under a name. " + + "Takes no arguments.", + parameters = noArgs(), + ) + + private val DISCOVER_SPEC = BuddyToolSpecDto( + name = DISCOVER_REPOSITORIES, + description = "List the repositories GitHub shows for an organisation or a user, read with one of " + + "the manager's own tokens, and which of them SprintStart already has. Use it before offering " + + "to connect repositories, so the names are real. Pass a token NAME from " + + "list_my_credential_names, never a token.", + parameters = toolFields( + ToolField( + "kind", + "Whether the owner is an organisation or a user.", + values = OWNER_KINDS.keys.toList(), + ), + ToolField("owner", "The organisation or user name."), + ToolField("token_name", "The name of one of the manager's stored tokens."), + ToolField("page", "Optional. Which page to read, from 0.", "integer"), + required = listOf("kind", "owner", "token_name"), + ), + ) + + private val PROJECT_SOURCES_SPEC = BuddyToolSpecDto( + name = LIST_PROJECT_SOURCES, + description = "The GitHub repositories connected to this project, each with whether it is enabled, " + + "when it last synced, and how many other projects share it. Read it before offering to link, " + + "unlink or sync one. Takes no arguments.", + parameters = noArgs(), + ) + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/repository/GithubRepositoryReadOutsideTransactionTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/repository/GithubRepositoryReadOutsideTransactionTest.kt new file mode 100644 index 00000000..a32236e9 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/repository/GithubRepositoryReadOutsideTransactionTest.kt @@ -0,0 +1,57 @@ +package com.sprintstart.sprintstartbackend.connectors.github.repository + +import com.sprintstart.sprintstartbackend.connectors.github.models.GithubRepositoryConnection +import com.sprintstart.sprintstartbackend.connectors.github.models.GithubRepositorySnapshot +import com.sprintstart.sprintstartbackend.connectors.github.models.GithubUser +import com.sprintstart.sprintstartbackend.connectors.github.models.GithubUserPat +import com.sprintstart.sprintstartbackend.shared.crypto.CryptoConfiguration +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID + +/** + * `GithubUpdatesService.updateRepository` is not transactional, and reads a connection's snapshot and + * token after loading it. Started from a REST request that works because the request holds a session; + * started from the buddy's confirm there may be none. This loads a connection the way that call does — + * with no transaction and no session — and checks that what a sync reads is there. + */ +@ActiveProfiles("test") +@DataJpaTest +@Import(CryptoConfiguration::class) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class GithubRepositoryReadOutsideTransactionTest { + @Autowired + private lateinit var repository: GithubRepositoryConnectionRepository + + @Autowired + private lateinit var entityManager: EntityManager + + @Autowired + private lateinit var transactionManager: PlatformTransactionManager + + @Test + fun `a connection loaded with no session still has its snapshot and its token`() { + val name = "repo-${UUID.randomUUID()}" + TransactionTemplate(transactionManager).executeWithoutResult { + val user = GithubUser(id = GithubUserPat("auth|pm", "work-$name"), token = "secret-$name") + entityManager.persist(user) + val connection = GithubRepositoryConnection(owner = "acme", name = name, user = user) + connection.snapshot = GithubRepositorySnapshot(repository = connection) + entityManager.persist(connection) + } + + val loaded = repository.findByOwnerAndName("acme", name)!! + + assertThat(loaded.snapshot).isNotNull + assertThat(loaded.user.token).isEqualTo("secret-$name") + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiServiceTest.kt new file mode 100644 index 00000000..c710f2e1 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiServiceTest.kt @@ -0,0 +1,168 @@ +package com.sprintstart.sprintstartbackend.connectors.github.service + +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubOwnerKind +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryRef +import com.sprintstart.sprintstartbackend.connectors.github.models.RepositoryConnectionOutcome +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.responses.DiscoverRepositoriesResponse +import com.sprintstart.sprintstartbackend.connectors.github.models.api.responses.DiscoveredRepository +import com.sprintstart.sprintstartbackend.connectors.github.models.api.responses.UpdateRepositoryResponse +import com.sprintstart.sprintstartbackend.connectors.github.models.exceptions.GithubUserPatNotFoundException +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 io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.util.UUID + +class GithubSourcesApiServiceTest { + private val users: GithubUserService = mockk(relaxed = true) + private val connector: GithubConnectorService = mockk(relaxed = true) + private val visibility: GithubRepositoryVisibilityService = mockk(relaxed = true) + private val projects: GithubRepositoryProjectService = mockk(relaxed = true) + private val updates: GithubUpdatesService = mockk(relaxed = true) + private val api = GithubSourcesApiService(users, connector, visibility, projects, updates) + + private val projectId = UUID.randomUUID() + private val ok = GithubRepositoryRef("acme", "ok") + private val bad = GithubRepositoryRef("acme", "bad") + + private fun status(e: Throwable?): HttpStatus = HttpStatus.valueOf( + (e as ResponseStatusException).statusCode.value(), + ) + + @Test + fun `token names are the stored names`() { + every { users.getAllPATNames("auth|pm") } returns listOf("work") + + assertThat(api.getTokenNames("auth|pm")).containsExactly("work") + } + + @Test + fun `discovery uses the caller's own auth id and picks the listing by owner kind`() = + runTest { + val request = DiscoverRepositoriesRequest("acme", "auth|pm", "work", 0, 20) + val found = DiscoverRepositoriesResponse(listOf(DiscoveredRepository("app", true, "u", true, false))) + coEvery { connector.discoverRepositoriesOfOrg(request) } returns found + coEvery { connector.discoverRepositoriesOfUser(request) } returns DiscoverRepositoriesResponse(emptyList()) + + val org = api.discoverRepositories("auth|pm", GithubOwnerKind.ORGANISATION, "acme", "work", 0, 20) + val user = api.discoverRepositories("auth|pm", GithubOwnerKind.USER, "acme", "work", 0, 20) + + assertThat(org.single().name).isEqualTo("app") + assertThat(org.single().alreadyConnected).isTrue() + assertThat(user).isEmpty() + } + + @Test + fun `a missing token becomes a 404 that names the token and never a value`() = + runTest { + coEvery { connector.discoverRepositoriesOfOrg(any()) } throws + GithubUserPatNotFoundException("work", "auth|pm") + + val failure = runCatching { + api.discoverRepositories("auth|pm", GithubOwnerKind.ORGANISATION, "acme", "work", 0, 20) + }.exceptionOrNull() + + assertThat(status(failure)).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(failure).hasMessageContaining("no GitHub token named “work”") + } + + @Test + fun `connecting is per repository, so one failing does not stop or hide the others`() = + runTest { + coEvery { + connector.connectRepositoryIfNecessary( + "auth|pm", + ConnectRepositoryRequest("acme", "ok", "work", projectId), + ) + } returns + RepositoryConnectionOutcome(UUID.randomUUID(), wasReused = true) + coEvery { + connector.connectRepositoryIfNecessary( + "auth|pm", + ConnectRepositoryRequest("acme", "bad", "work", projectId), + ) + } throws + RepositoryNotFoundException("acme", "bad") + + val results = api.connectRepositories("auth|pm", projectId, "work", listOf(bad, ok)) + + assertThat(results.map { it.repository }).containsExactly(bad, ok) + assertThat(results[0].failure).isEqualTo("Repository acme/bad not found") + assertThat(results[1].failure).isNull() + assertThat(results[1].reused).isTrue() + } + + @Test + fun `an unexpected failure while connecting is reported without its internals`() = + runTest { + coEvery { connector.connectRepositoryIfNecessary(any(), any()) } throws + IllegalStateException("jdbc:secret@host") + + val result = api.connectRepositories("auth|pm", projectId, "work", listOf(ok)).single() + + assertThat(result.failure).isNotNull().doesNotContain("jdbc").doesNotContain("secret") + } + + @Test + fun `linking checks that the caller can see the repository before it links`() = + runTest { + val id = UUID.randomUUID() + every { projects.addProjectToRepository("auth|pm", id, projectId) } returns setOf(projectId) + + api.linkRepository("auth|pm", projectId, id) + + coVerifyOrder { + visibility.requireCallerCanSeeConnection("auth|pm", id) + projects.addProjectToRepository("auth|pm", id, projectId) + } + } + + @Test + fun `a repository the caller cannot see is not linked, and is a 404`() = + runTest { + val id = UUID.randomUUID() + coEvery { visibility.requireCallerCanSeeConnection("auth|pm", id) } throws + RepositoryNotFoundException("", "", "not found") + + val failure = runCatching { api.linkRepository("auth|pm", projectId, id) }.exceptionOrNull() + + assertThat(status(failure)).isEqualTo(HttpStatus.NOT_FOUND) + verify(exactly = 0) { projects.addProjectToRepository(any(), any(), any()) } + } + + @Test + fun `unlinking passes the project and the caller through`() { + val id = UUID.randomUUID() + every { projects.removeProjectFromRepository("auth|pm", id, projectId) } returns emptySet() + + assertThat(api.unlinkRepository("auth|pm", projectId, id)).isEmpty() + } + + @Test + fun `syncing returns the transaction id`() { + val transaction = UUID.randomUUID() + every { updates.updateRepository(any(), true) } returns UpdateRepositoryResponse(transaction) + + assertThat(api.syncRepository(ok)).isEqualTo(transaction) + } + + @Test + fun `syncing something not connected is a 404, and one not yet fetched is a 400`() { + every { updates.updateRepository(any(), true) } throws RepositoryNotConnectedException("acme", "ok") + assertThat(status(runCatching { api.syncRepository(ok) }.exceptionOrNull())).isEqualTo(HttpStatus.NOT_FOUND) + + every { updates.updateRepository(any(), true) } throws RepositoryNotInitializedException("acme", "ok") + assertThat(status(runCatching { api.syncRepository(ok) }.exceptionOrNull())).isEqualTo(HttpStatus.BAD_REQUEST) + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScopeTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScopeTest.kt new file mode 100644 index 00000000..0e0b2216 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScopeTest.kt @@ -0,0 +1,78 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import io.mockk.every +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class GithubSourcesScopeTest { + private val f = SourcesFixture() + + @Test + fun `a repository that is not connected is not found`() { + assertThat(f.scope.find("acme", "app", f.projectId)).isNull() + } + + @Test + fun `says whether it is linked here and how many other projects share it`() { + f.connected("acme", "app", linkedHere = true, others = 2) + f.connected("acme", "lib", linkedHere = false, others = 1) + + val app = f.scope.find("acme", "app", f.projectId)!! + val lib = f.scope.find("acme", "lib", f.projectId)!! + + assertThat(app.linkedHere).isTrue() + assertThat(app.otherProjects).isEqualTo(2) + assertThat(lib.linkedHere).isFalse() + assertThat(lib.otherProjects).isEqualTo(1) + } + + @Test + fun `a repository that disappears between the two lookups is not found`() { + val id = UUID.randomUUID() + every { f.githubRepositoryApi.getRepositoryIdByOwnerAndName("acme", "app") } returns id + every { f.githubRepositoryApi.getRepositoryProjectIdsById(id) } throws NoSuchElementException() + + assertThat(f.scope.find("acme", "app", f.projectId)).isNull() + } + + @Test + fun `a name of one of the manager's tokens passes`() { + assertThat(f.scope.tokenProblem("auth|pm", "work")).isNull() + } + + @Test + fun `a name that is not one of their tokens is refused`() { + assertThat(f.scope.tokenProblem("auth|pm", "somebody-elses")).contains("no GitHub token with that name") + assertThat(f.scope.tokenProblem("auth|pm", "")).contains("A token name is needed") + } + + @Test + fun `something shaped like a token is refused and never repeated`() { + listOf("ghp_abcdefghijklmnopqrstuvwxyz0123456789", "github_pat_11ABCDEFG0123456789_xyz", "gho_secret", "GHS_x") + .forEach { pasted -> + val problem = f.scope.tokenProblem("auth|pm", pasted) + + assertThat(problem).describedAs(pasted).contains("Never paste a token") + assertThat(problem).describedAs(pasted).doesNotContain(pasted) + } + } + + @Test + fun `the shared note is empty for a repository nobody else uses`() { + f.connected("acme", "app", linkedHere = true, others = 0) + + assertThat(f.scope.sharedNote(f.scope.find("acme", "app", f.projectId)!!, "They get the update too.")).isEmpty() + } + + @Test + fun `the shared note counts the other projects`() { + f.connected("acme", "one", linkedHere = true, others = 1) + f.connected("acme", "three", linkedHere = true, others = 3) + + assertThat(f.scope.sharedNote(f.scope.find("acme", "one", f.projectId)!!, "They get the update too.")) + .isEqualTo("One other project shares this repository. They get the update too.") + assertThat(f.scope.sharedNote(f.scope.find("acme", "three", f.projectId)!!, "They get the update too.")) + .isEqualTo("3 other projects share this repository. They get the update too.") + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActionsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActionsTest.kt new file mode 100644 index 00000000..ab3a6cbe --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActionsTest.kt @@ -0,0 +1,404 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubConnectResultDto +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryRef +import com.sprintstart.sprintstartbackend.onboarding.external.enums.BuddyProposalRisk +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.util.UUID + +class GithubSourcesTeamActionsTest { + private val f = SourcesFixture() + + @Nested + inner class Connect { + private val action = ConnectRepositoriesAction(f.scope, f.githubSourcesApi) + + private fun connect(token: String, vararg repos: Pair) = + f.call( + "connect_repositories", + "token_name" to token, + "repositories" to JsonArray(repos.map { f.repo(it.first, it.second) }), + ) + + @Test + fun `is a bulk change in the sources area`() { + assertThat(action.area).isEqualTo(TeamArea.SOURCES) + assertThat(action.risk).isEqualTo(BuddyProposalRisk.BULK) + } + + @Test + fun `says which are new and which are only linked, and that the token stays with the connection`() { + f.connected("acme", "shared", linkedHere = false, others = 1) + + val draft = f.proposed(action.draft(connect("work", "acme" to "fresh", "acme" to "shared"), f.context)) + + assertThat(draft.label).isEqualTo("Connect 2 repositories") + assertThat(draft.preview).contains( + "using your token “work”", + "acme/fresh — new: starts fetching", + "acme/shared — already connected for another project: linked here, nothing fetched again", + "GitHub is asked whether your token can see each one", + "keeps using your token “work” for its nightly updates", + ) + } + + @Test + fun `a repository already on this project is left out and named`() { + f.connected("acme", "mine", linkedHere = true) + + val draft = f.proposed(action.draft(connect("work", "acme" to "mine", "acme" to "fresh"), f.context)) + + assertThat(draft.label).isEqualTo("Connect acme/fresh") + assertThat(draft.preview).contains("Left out, already on this project: acme/mine") + assertThat(draft.params.objectArray("repositories")).hasSize(1) + } + + @Test + fun `everything already being here is refused`() { + f.connected("acme", "mine", linkedHere = true) + + assertThat(f.refusal(action.draft(connect("work", "acme" to "mine"), f.context))) + .contains("already connected to this project") + } + + @Test + fun `a token name that is not the manager's is refused`() { + assertThat(f.refusal(action.draft(connect("nope", "acme" to "app"), f.context))) + .contains("no GitHub token with that name") + } + + @Test + fun `a pasted token is refused, and appears in no refusal, preview or stored params`() { + val pasted = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + + val reason = f.refusal(action.draft(connect(pasted, "acme" to "app"), f.context)) + + assertThat(reason).contains("Never paste a token").doesNotContain(pasted) + } + + @Test + fun `no parameter takes a token value`() { + val names = (action.spec.parameters["properties"] as JsonObject).keys + + assertThat(names).containsExactlyInAnyOrder("token_name", "repositories") + assertThat(action.spec.description).contains("NAME").contains("Never pass a token") + } + + @Test + fun `nothing, or too many, is refused`() { + assertThat(f.refusal(action.draft(connect("work"), f.context))).contains("No repository was given") + + val many = (1..21).map { "acme" to "r$it" }.toTypedArray() + assertThat(f.refusal(action.draft(connect("work", *many), f.context))).contains("At most 20") + } + + @Test + fun `a confirm is turned down once everything is on the project`() { + f.connected("acme", "app", linkedHere = true) + val params = f.json("token_name" to "work", "repositories" to JsonArray(listOf(f.repo("acme", "app")))) + + assertThat(action.recheck(params, f.context)).contains("has been connected to this project since") + } + + @Test + fun `a confirm is turned down when the token was removed since`() { + val params = f.json("token_name" to "gone", "repositories" to JsonArray(listOf(f.repo("acme", "app")))) + + assertThat(action.recheck(params, f.context)).contains("no GitHub token with that name") + } + + @Test + fun `performing reports each repository, and one failing does not hide the others`() = + runTest { + val ok = GithubRepositoryRef("acme", "ok") + val bad = GithubRepositoryRef("acme", "bad") + coEvery { + f.githubSourcesApi.connectRepositories( + "auth|pm", + f.projectId, + "work", + listOf(ok, bad), + ) + } returns + listOf( + GithubConnectResultDto(ok, reused = false, failure = null), + GithubConnectResultDto(bad, reused = false, failure = "Repository acme/bad not found"), + ) + val params = f.json( + "token_name" to "work", + "repositories" to JsonArray(listOf(f.repo("acme", "ok"), f.repo("acme", "bad"))), + ) + + val result = action.perform(params, f.context) + + assertThat( + result, + ).contains( + "Connected acme/ok", + "data-ingestion page", + "Not connected: acme/bad — Repository acme/bad not found", + ) + } + + @Test + fun `performing when none connect fails with the reasons`() = + runTest { + val bad = GithubRepositoryRef("acme", "bad") + coEvery { f.githubSourcesApi.connectRepositories(any(), any(), any(), any()) } returns + listOf(GithubConnectResultDto(bad, reused = false, failure = "Repository acme/bad not found")) + val params = f.json("token_name" to "work", "repositories" to JsonArray(listOf(f.repo("acme", "bad")))) + + assertThat(runCatching { action.perform(params, f.context) }.exceptionOrNull()) + .hasMessageContaining("None could be connected") + .hasMessageContaining("acme/bad") + } + + @Test + fun `linking only what was already connected says nothing about fetching`() = + runTest { + val shared = GithubRepositoryRef("acme", "shared") + coEvery { f.githubSourcesApi.connectRepositories(any(), any(), any(), any()) } returns + listOf(GithubConnectResultDto(shared, reused = true, failure = null)) + val params = f.json( + "token_name" to "work", + "repositories" to JsonArray(listOf(f.repo("acme", "shared"))), + ) + + assertThat(action.perform(params, f.context)).doesNotContain("fetch") + } + + @Test + fun `drafting connects nothing`() { + action.draft(connect("work", "acme" to "app"), f.context) + + coVerify(exactly = 0) { f.githubSourcesApi.connectRepositories(any(), any(), any(), any()) } + } + } + + @Nested + inner class Link { + private val action = LinkRepositoryAction(f.scope, f.githubSourcesApi) + + @Test + fun `is a standard change`() { + assertThat(action.risk).isEqualTo(BuddyProposalRisk.STANDARD) + } + + @Test + fun `previews what a link reaches, that nothing is fetched, and that GitHub is asked`() { + f.connected("acme", "app", linkedHere = false, others = 2) + + val draft = f.proposed( + action.draft(f.call("link_repository", "owner" to "acme", "name" to "app"), f.context), + ) + + assertThat(draft.preview).contains( + "Link acme/app to this project", + "reach this project's hires and the buddy", + "Nothing is fetched again", + "asked whether you can see it", + "2 other projects share this repository", + ) + } + + @Test + fun `a repository that is not connected is pointed at connect`() { + assertThat( + f.refusal(action.draft(f.call("link_repository", "owner" to "acme", "name" to "app"), f.context)), + ).contains("not connected to SprintStart", "connect_repositories") + } + + @Test + fun `a repository already linked is refused`() { + f.connected("acme", "app", linkedHere = true) + + assertThat( + f.refusal(action.draft(f.call("link_repository", "owner" to "acme", "name" to "app"), f.context)), + ).contains("already linked") + } + + @Test + fun `a call that names no repository is refused`() { + assertThat(f.refusal(action.draft(f.call("link_repository", "owner" to "acme"), f.context))) + .contains("owner and name") + } + + @Test + fun `a confirm is turned down when it was linked since or is gone`() { + f.connected("acme", "app", linkedHere = true) + assertThat(action.recheck(f.repo("acme", "app"), f.context)).contains("linked to this project since") + + assertThat(action.recheck(f.repo("acme", "other"), f.context)).contains("no longer connected") + } + + @Test + fun `performing links by the connection's id for the manager`() = + runTest { + val id = f.connected("acme", "app", linkedHere = false) + coEvery { f.githubSourcesApi.linkRepository("auth|pm", f.projectId, id) } returns setOf(f.projectId) + + action.perform(f.repo("acme", "app"), f.context) + + coVerify { f.githubSourcesApi.linkRepository("auth|pm", f.projectId, id) } + } + + @Test + fun `a refusal from GitHub's visibility check is passed on`() = + runTest { + val id = f.connected("acme", "secret", linkedHere = false) + coEvery { f.githubSourcesApi.linkRepository(any(), any(), id) } throws + ResponseStatusException(HttpStatus.NOT_FOUND, "Repository acme/secret not found") + + assertThat(runCatching { action.perform(f.repo("acme", "secret"), f.context) }.exceptionOrNull()) + .hasMessageContaining("not found") + } + } + + @Nested + inner class Unlink { + private val action = UnlinkRepositoryAction(f.scope, f.githubSourcesApi) + + private fun unlink(name: String) = f.call("unlink_repository", "owner" to "acme", "name" to name) + + @Test + fun `is destructive`() { + assertThat(action.risk).isEqualTo(BuddyProposalRisk.DESTRUCTIVE) + } + + @Test + fun `says only this project goes when others use it`() { + f.connected("acme", "app", linkedHere = true, others = 1) + + val draft = f.proposed(action.draft(unlink("app"), f.context)) + + assertThat(draft.preview).contains( + "Take acme/app off this project", + "leave this project's starter-work pool", + "The connection stays, and one other project keeps it", + ) + } + + @Test + fun `says it stays connected but reaches nobody when nothing else uses it`() { + f.connected("acme", "app", linkedHere = true, others = 0) + + assertThat(f.proposed(action.draft(unlink("app"), f.context)).preview) + .contains("stays connected but reaches no project") + } + + @Test + fun `counts several other projects`() { + f.connected("acme", "app", linkedHere = true, others = 3) + + assertThat(f.proposed(action.draft(unlink("app"), f.context)).preview) + .contains("3 other projects keep it") + } + + @Test + fun `a repository not linked to this project is refused, even when it is connected elsewhere`() { + f.connected("acme", "theirs", linkedHere = false, others = 1) + + assertThat(f.refusal(action.draft(unlink("theirs"), f.context))).contains("not linked to this project") + assertThat(f.refusal(action.draft(unlink("unknown"), f.context))).contains("not linked to this project") + } + + @Test + fun `a confirm is turned down when it is no longer linked`() { + f.connected("acme", "app", linkedHere = false) + + assertThat(action.recheck(f.repo("acme", "app"), f.context)).contains("no longer linked") + } + + @Test + fun `performing unlinks this project only, by the connection's id`() = + runTest { + val id = f.connected("acme", "app", linkedHere = true, others = 1) + every { f.githubSourcesApi.unlinkRepository("auth|pm", f.projectId, id) } returns emptySet() + + action.perform(f.repo("acme", "app"), f.context) + + verify { f.githubSourcesApi.unlinkRepository("auth|pm", f.projectId, id) } + } + + @Test + fun `performing for a repository that left the project fails instead of unlinking something else`() = + runTest { + f.connected("acme", "app", linkedHere = false) + + assertThat(runCatching { action.perform(f.repo("acme", "app"), f.context) }.exceptionOrNull()) + .hasMessageContaining("not linked to this project") + verify(exactly = 0) { f.githubSourcesApi.unlinkRepository(any(), any(), any()) } + } + } + + @Nested + inner class Sync { + private val action = SyncRepositoryAction(f.scope, f.githubSourcesApi) + + private fun sync(name: String) = f.call("sync_repository", "owner" to "acme", "name" to name) + + @Test + fun `is a bulk change`() { + assertThat(action.risk).isEqualTo(BuddyProposalRisk.BULK) + } + + @Test + fun `says it runs in the background, and that the projects sharing it get the update`() { + f.connected("acme", "app", linkedHere = true, others = 2) + + val draft = f.proposed(action.draft(sync("app"), f.context)) + + assertThat(draft.preview).contains( + "Fetch the latest", + "runs in the background", + "data-ingestion page", + "2 other projects share this repository. They get the update too.", + ) + } + + @Test + fun `a repository nobody else shares has no shared note`() { + f.connected("acme", "app", linkedHere = true) + + assertThat(f.proposed(action.draft(sync("app"), f.context)).preview).doesNotContain("share") + } + + @Test + fun `a repository not linked to this project cannot be synced, though it is connected`() { + f.connected("acme", "theirs", linkedHere = false, others = 1) + + assertThat(f.refusal(action.draft(sync("theirs"), f.context))).contains("not linked to this project") + assertThat(f.refusal(action.draft(sync("unknown"), f.context))).contains("not linked to this project") + } + + @Test + fun `a confirm is turned down when it left the project since`() { + f.connected("acme", "app", linkedHere = false) + + assertThat(action.recheck(f.repo("acme", "app"), f.context)).contains("no longer linked") + } + + @Test + fun `performing starts the sync of that repository`() = + runTest { + every { f.githubSourcesApi.syncRepository(GithubRepositoryRef("acme", "app")) } returns + UUID.randomUUID() + + val result = action.perform(f.repo("acme", "app"), f.context) + + verify { f.githubSourcesApi.syncRepository(GithubRepositoryRef("acme", "app")) } + assertThat(result).contains("fetching in the background", "data-ingestion page") + } + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesAreaMountTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesAreaMountTest.kt new file mode 100644 index 00000000..5b30d2ba --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesAreaMountTest.kt @@ -0,0 +1,78 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.json.JsonObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.ObjectProvider +import java.time.Clock + +/** What opening the sources area hands the model, through the same mounting the buddy uses. */ +class SourcesAreaMountTest { + private val f = SourcesFixture() + + private val handlers: List = listOf( + ConnectRepositoriesAction(f.scope, f.githubSourcesApi), + LinkRepositoryAction(f.scope, f.githubSourcesApi), + UnlinkRepositoryAction(f.scope, f.githubSourcesApi), + SyncRepositoryAction(f.scope, f.githubSourcesApi), + ) + + private val reads = SourcesTeamTools(f.githubSourcesApi, f.githubRepositoryApi, f.scope) + + private val proposals: BuddyProposalService = run { + val provider: ObjectProvider = mockk() + every { provider.orderedStream() } answers { handlers.stream() } + BuddyProposalService(mockk(), mockk(), provider, Clock.systemUTC()) + } + + private fun propertyNames(schema: JsonObject): Set = (schema["properties"] as? JsonObject)?.keys.orEmpty() + + @Test + fun `opening the sources area mounts exactly its reads and actions, and none of the global operations`() { + val mounted = proposals.actionSpecs(setOf(TeamArea.SOURCES)).map { it.name } + reads.toolSpecs().map { it.name } + + assertThat(mounted).containsExactlyInAnyOrder( + "list_my_credential_names", + "discover_repositories", + "list_project_sources", + "connect_repositories", + "link_repository", + "unlink_repository", + "sync_repository", + ) + assertThat(mounted.filter { it.contains("all") || it.contains("configure") || it.contains("credential_") }) + .containsExactly("list_my_credential_names") + } + + @Test + fun `no tool anywhere in the area accepts a token value`() { + val specs = handlers.map { it.spec } + reads.toolSpecs() + + specs.forEach { spec -> + assertThat(propertyNames(spec.parameters)) + .describedAs(spec.name) + .allMatch { it == "token_name" || it !in setOf("token", "pat", "secret", "password", "credential") } + } + } + + @Test + fun `the risk of each action matches what it does`() { + val risks = handlers.associate { it.spec.name to it.risk.name } + + assertThat(risks) + .containsEntry("connect_repositories", "BULK") + .containsEntry("sync_repository", "BULK") + .containsEntry("link_repository", "STANDARD") + .containsEntry("unlink_repository", "DESTRUCTIVE") + } + + @Test + fun `no two tools in the area share a name, and every action belongs to it`() { + val names = handlers.map { it.spec.name } + reads.toolSpecs().map { it.name } + + assertThat(names).doesNotHaveDuplicates() + assertThat(handlers.map { it.area }).containsOnly(TeamArea.SOURCES) + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesFixture.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesFixture.kt new file mode 100644 index 00000000..22fc9e63 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesFixture.kt @@ -0,0 +1,64 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubRepositoryApi +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubSourcesApi +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.assertj.core.api.Assertions.assertThat +import java.util.UUID + +/** A manager with two stored GitHub tokens and a project, for the sources area's tests. */ +internal class SourcesFixture { + val githubRepositoryApi: GithubRepositoryApi = mockk(relaxed = true) + val githubSourcesApi: GithubSourcesApi = mockk(relaxed = true) + val scope = GithubSourcesScope(githubRepositoryApi, githubSourcesApi) + + val projectId: UUID = UUID.randomUUID() + val context = TeamToolContext(userId = UUID.randomUUID(), authId = "auth|pm", projectId = projectId) + + init { + every { githubSourcesApi.getTokenNames("auth|pm") } returns listOf("work", "personal") + every { githubRepositoryApi.getRepositoryIdByOwnerAndName(any(), any()) } returns null + } + + /** A repository that is connected, linked to this project or not, and to [others] other projects. */ + fun connected(owner: String, name: String, linkedHere: Boolean, others: Int = 0): UUID { + val id = UUID.randomUUID() + every { githubRepositoryApi.getRepositoryIdByOwnerAndName(owner, name) } returns id + every { githubRepositoryApi.getRepositoryProjectIdsById(id) } returns + (if (linkedHere) setOf(projectId) else emptySet()) + List(others) { UUID.randomUUID() } + return id + } + + fun call(name: String, vararg args: Pair) = + BuddyToolCallDto(id = "c1", name = name, arguments = json(*args)) + + fun json(vararg args: Pair): JsonObject = + buildJsonObject { + args.forEach { (key, value) -> + when (value) { + is Number -> put(key, value) + is JsonElement -> put(key, value) + else -> put(key, JsonPrimitive(value.toString())) + } + } + } + + fun repo(owner: String, name: String) = json("owner" to owner, "name" to name) + + fun proposed(draft: TeamActionDraft): TeamActionDraft.Proposed { + assertThat(draft).isInstanceOf(TeamActionDraft.Proposed::class.java) + return draft as TeamActionDraft.Proposed + } + + fun refusal(draft: TeamActionDraft): String { + assertThat(draft).isInstanceOf(TeamActionDraft.Refused::class.java) + return (draft as TeamActionDraft.Refused).reason + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamToolsTest.kt new file mode 100644 index 00000000..7986dde1 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamToolsTest.kt @@ -0,0 +1,164 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubDiscoveredRepositoryDto +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubOwnerKind +import com.sprintstart.sprintstartbackend.connectors.github.external.GithubSourceInstanceDto +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.time.Instant + +class SourcesTeamToolsTest { + private val f = SourcesFixture() + private val tools = SourcesTeamTools(f.githubSourcesApi, f.githubRepositoryApi, f.scope) + + private fun read(name: String, vararg args: Pair) = tools.execute(f.call(name, *args), f.context) + + @Test + fun `mounts exactly the reads of the area, in the sources area`() { + assertThat(tools.area).isEqualTo(TeamArea.SOURCES) + assertThat(tools.toolSpecs().map { it.name }) + .containsExactly("list_my_credential_names", "discover_repositories", "list_project_sources") + assertThat(tools.toolSpecs().all { tools.handles(it.name) }).isTrue() + } + + @Test + fun `credential names are the names and nothing else`() { + val text = read("list_my_credential_names") + + assertThat(text).contains("- personal", "- work", "names only", "settings page") + } + + @Test + fun `with no token stored it points to the settings page and warns against pasting one`() { + every { f.githubSourcesApi.getTokenNames("auth|pm") } returns emptyList() + + assertThat( + read("list_my_credential_names"), + ).contains("no GitHub token", "settings page", "never ask them to paste") + } + + @Test + fun `no read tool takes a token value`() { + tools.toolSpecs().forEach { spec -> + val properties = (spec.parameters["properties"] as kotlinx.serialization.json.JsonObject).keys + assertThat(properties).describedAs(spec.name).doesNotContain("token", "pat", "secret", "password") + } + } + + @Test + fun `discovery lists what GitHub shows and marks what is already connected`() { + coEvery { + f.githubSourcesApi.discoverRepositories("auth|pm", GithubOwnerKind.ORGANISATION, "acme", "work", 0, 20) + } returns + listOf( + GithubDiscoveredRepositoryDto( + "app", + isPrivate = true, + url = "u", + alreadyConnected = true, + enabled = false, + ), + GithubDiscoveredRepositoryDto( + "lib", + isPrivate = false, + url = "u", + alreadyConnected = false, + enabled = null, + ), + ) + + val text = read("discover_repositories", "kind" to "organisation", "owner" to "acme", "token_name" to "work") + + assertThat(text).contains("- app (private) — already connected, disabled", "- lib") + assertThat(text).doesNotContain("- lib (private)").doesNotContain("- lib — already") + } + + @Test + fun `discovery refuses a token name that is not the manager's before asking GitHub`() { + val text = read("discover_repositories", "kind" to "user", "owner" to "sam", "token_name" to "nope") + + assertThat(text).contains("no GitHub token with that name") + coVerify(exactly = 0) { f.githubSourcesApi.discoverRepositories(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `a token pasted as the token name is refused and not repeated`() { + val pasted = "github_pat_11ABCDEFG0123456789_secretsecret" + + val text = read("discover_repositories", "kind" to "user", "owner" to "sam", "token_name" to pasted) + + assertThat(text).contains("Never paste a token").doesNotContain(pasted) + } + + @Test + fun `an unknown kind is refused, and a GitHub failure is reported as its reason`() { + assertThat(read("discover_repositories", "kind" to "team", "owner" to "x", "token_name" to "work")) + .contains("organisation or user") + + coEvery { f.githubSourcesApi.discoverRepositories(any(), any(), any(), any(), any(), any()) } throws + ResponseStatusException(HttpStatus.NOT_FOUND, "There is no GitHub token named “work”.") + assertThat(read("discover_repositories", "kind" to "user", "owner" to "x", "token_name" to "work")) + .contains("There is no GitHub token named") + } + + @Test + fun `an empty page says so, and a full page says there may be more`() { + coEvery { f.githubSourcesApi.discoverRepositories(any(), any(), any(), any(), 0, any()) } returns emptyList() + coEvery { f.githubSourcesApi.discoverRepositories(any(), any(), any(), any(), 1, any()) } returns + List(20) { GithubDiscoveredRepositoryDto("r$it", false, "u", false, null) } + + assertThat(read("discover_repositories", "kind" to "user", "owner" to "x", "token_name" to "work")) + .contains("lists no repositories") + assertThat(read("discover_repositories", "kind" to "user", "owner" to "x", "token_name" to "work", "page" to 1)) + .contains("ask again with page 2") + } + + @Test + fun `project sources list this project's repositories and say which are shared`() { + f.connected("acme", "app", linkedHere = true, others = 2) + f.connected("acme", "solo", linkedHere = true, others = 0) + every { f.githubRepositoryApi.getSourceInstances(f.projectId) } returns + listOf( + GithubSourceInstanceDto( + java.util.UUID.randomUUID(), + "acme", + "app", + "CONNECTED", + true, + Instant.parse("2026-09-01T10:00:00Z"), + null, + null, + ), + GithubSourceInstanceDto( + java.util.UUID.randomUUID(), + "acme", + "solo", + "DISABLED", + false, + null, + null, + null, + ), + ) + + val text = read("list_project_sources") + + assertThat(text).contains( + "acme/app — connected, enabled, commits last synced 2026-09-01 — shared with 2 other projects", + "acme/solo — disabled, disabled", + ) + assertThat(text).doesNotContain("acme/solo — disabled, disabled — shared") + } + + @Test + fun `a project with nothing connected says so`() { + every { f.githubRepositoryApi.getSourceInstances(f.projectId) } returns emptyList() + + assertThat(read("list_project_sources")).contains("no GitHub repository connected") + } +}