From bb4f01fb5444dd1a71b7334f1896b55822b996df Mon Sep 17 00:00:00 2001 From: Linus Date: Mon, 21 Sep 2026 16:25:13 +0200 Subject: [PATCH 1/2] Publish the GitHub connector's source operations to other modules The team-mode buddy needs to connect, link, unlink and sync repositories and read a manager's token names, but none of that was reachable from outside the connector: only read-only lookups were published, and onboarding may only import a module's `external` package. GithubSourcesApi wraps the services the REST controllers already call, with the caller's authId throughout, since GitHub access here is always the caller's own. It returns and accepts token names, never token values. Connecting is per repository, so one failing does not stop or hide the others. Linking asks whether the caller can see the repository before it links, as the REST endpoint does, and the connector's own exceptions become ResponseStatusExceptions with messages safe to show a person. Also adds a regression test that a connection loaded with no session still has the snapshot and token a sync reads, because updateRepository is not transactional and the buddy calls it without the request session REST has. Co-Authored-By: Claude Sonnet 5 --- .../github/external/GithubSourcesApi.kt | 117 ++++++++++++ .../github/service/GithubSourcesApiService.kt | 139 +++++++++++++++ ...hubRepositoryReadOutsideTransactionTest.kt | 57 ++++++ .../service/GithubSourcesApiServiceTest.kt | 168 ++++++++++++++++++ 4 files changed, 481 insertions(+) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/external/GithubSourcesApi.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiService.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/repository/GithubRepositoryReadOutsideTransactionTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/connectors/github/service/GithubSourcesApiServiceTest.kt 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/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) + } +} From ce6d7e9254ee8179ef256d5cccfbed9aebdfe81d Mon Sep 17 00:00:00 2001 From: Linus Date: Mon, 21 Sep 2026 16:25:13 +0200 Subject: [PATCH 2/2] Let the team-mode buddy manage a project's GitHub repositories First slice of the sources area (backend#229): list_my_credential_names, discover_repositories, list_project_sources, and the actions connect_repositories (bulk), link_repository, unlink_repository (destructive) and sync_repository (bulk). Jira, Confluence, source toggles and uploads follow separately. A repository is one connection however many projects use it, so a sync or an unlink reaches past the turn's project. Both are only offered for a repository linked to the turn's project, and every preview says how many other projects share it and what that means for them. A link is offered for any connected repository and GitHub is asked whether the manager can see it when they confirm. Credentials are names only. No tool parameter accepts a token, the tool descriptions tell the model to refuse one pasted into the conversation, and a token name shaped like a GitHub token is refused without being repeated in the refusal, the preview or the stored proposal. The preview of a connect says the new repository keeps using the manager's token for its nightly updates. Co-Authored-By: Claude Sonnet 5 --- .../onboarding/service/GithubSourcesScope.kt | 81 ++++ .../service/GithubSourcesTeamActions.kt | 373 ++++++++++++++++ .../onboarding/service/SourcesTeamTools.kt | 179 ++++++++ .../service/GithubSourcesScopeTest.kt | 78 ++++ .../service/GithubSourcesTeamActionsTest.kt | 404 ++++++++++++++++++ .../service/SourcesAreaMountTest.kt | 78 ++++ .../onboarding/service/SourcesFixture.kt | 64 +++ .../service/SourcesTeamToolsTest.kt | 164 +++++++ 8 files changed, 1421 insertions(+) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScope.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActions.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamTools.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesScopeTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/GithubSourcesTeamActionsTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesAreaMountTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesFixture.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/SourcesTeamToolsTest.kt 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/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") + } +}