diff --git a/.eslintignore b/.eslintignore index c73074efb3..46bb1ad2b2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,3 +7,5 @@ **/.git/** **/public/** **/microsite/** +**/templates/** +**/sample-templates/** diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index fa4b8c23da..bf01f1972c 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -471,6 +471,7 @@ Describes the following entity kind: An API describes an interface that can be exposed by a component. The API can be defined in different formats, like [OpenAPI](https://swagger.io/specification/), [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/), +[GraphQL](https://graphql.org/learn/schema/), [gRPC](https://developers.google.com/protocol-buffers), or other formats. Descriptor files for this kind may look as follows. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 18de63d704..d6e81e639f 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -51,13 +51,13 @@ The value of this annotation is a location reference string (see above). If this annotation is specified, it is expected to point to a repository that the TechDocs system can read and generate docs from. -### backstage.io/jenkins-github-folder +### jenkins.io/github-folder ```yaml # Example: metadata: annotations: - backstage.io/jenkins-github-folder: folder-name/job-name + jenkins.io/github-folder: folder-name/job-name ``` The value of this annotation is the path to a job on Jenkins, that builds this diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index eea78e3571..ef0afb76f7 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -90,16 +90,19 @@ import { GithubPublisher, CreateReactAppTemplater, Templaters, + RepoVisilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; -export default async function createPlugin({ logger }: PluginEnvironment) { +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); - // Register default templaters templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); @@ -112,9 +115,17 @@ export default async function createPlugin({ logger }: PluginEnvironment) { preparers.register('file', filePreparer); preparers.register('github', githubPreparer); - // Create GitHub client with your access token from environment variables - const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); - const publisher = new GithubPublisher({ client: githubClient }); + const githubToken = config.getString('scaffolder.github.token'); + const repoVisibility = config.getString( + 'scaffolder.github.visibility', + ) as RepoVisilityOptions; + + const githubClient = new Octokit({ auth: githubToken }); + const publisher = new GithubPublisher({ + client: githubClient, + token: githubToken, + repoVisibility, + }); const dockerClient = new Docker(); return await createRouter({ @@ -177,8 +188,23 @@ docs on creating private GitHub access tokens is available Note that the need for private GitHub access tokens will be replaced with GitHub Apps integration further down the line. -The GitHub access token is passed along using the `GITHUB_ACCESS_TOKEN` -environment variable. +The Github access token is retrieved from environment variables via the config. +The config file needs to specify what environment variable the token is +retrieved from. Your config should have the following objects. + +```yaml +scaffolder: + github: + token: + $secret: + env: GITHUB_ACCESS_TOKEN + visibility: public # or 'internal' or 'private' +``` + +You can configure who can see the new repositories that the scaffolder creates +by specifying `visibility` option. Valid options are `public`, `private` and +`internal`. `internal` options is for GitHub Enterprise clients, which means +public within the organization. ### Running the Backend diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 257fde3c24..c382abc3a8 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -17,6 +17,11 @@ import { Router as GitHubActionsRouter, isPluginApplicableToEntity as isGitHubActionsAvailable, } from '@backstage/plugin-github-actions'; +import { + Router as JenkinsRouter, + isPluginApplicableToEntity as isJenkinsAvailable, + LatestRunCard as JenkinsLatestRunCard, +} from '@backstage/plugin-jenkins'; import { Router as CircleCIRouter, isPluginApplicableToEntity as isCircleCIAvailable, @@ -38,6 +43,8 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. // You can for example enforce that all components of type 'service' should use GitHubActions switch (true) { + case isJenkinsAvailable(entity): + return ; case isGitHubActionsAvailable(entity): return ; case isCircleCIAvailable(entity): @@ -57,6 +64,11 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( + {isJenkinsAvailable(entity) && ( + + + + )} ); diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index a9e5f185ec..abbcdae1ae 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -23,12 +23,16 @@ import { GithubPublisher, CreateReactAppTemplater, Templaters, + RepoVisilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; -export default async function createPlugin({ logger }: PluginEnvironment) { +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); @@ -42,8 +46,17 @@ export default async function createPlugin({ logger }: PluginEnvironment) { preparers.register('file', filePreparer); preparers.register('github', githubPreparer); - const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); - const publisher = new GithubPublisher({ client: githubClient }); + const githubToken = config.getString('scaffolder.github.token'); + const repoVisibility = config.getString( + 'scaffolder.github.visibility', + ) as RepoVisilityOptions; + + const githubClient = new Octokit({ auth: githubToken }); + const publisher = new GithubPublisher({ + client: githubClient, + token: githubToken, + repoVisibility, + }); const dockerClient = new Docker(); return await createRouter({ diff --git a/packages/catalog-model/examples/swapi-graphql.yaml b/packages/catalog-model/examples/swapi-graphql.yaml new file mode 100644 index 0000000000..152d9c0afa --- /dev/null +++ b/packages/catalog-model/examples/swapi-graphql.yaml @@ -0,0 +1,1174 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: starwars-graphql + description: SWAPI GraphQL Schema +spec: + type: graphql + definition: | + schema { + query: Root + } + + """A single film.""" + type Film implements Node { + """The title of this film.""" + title: String + + """The episode number of this film.""" + episodeID: Int + + """The opening paragraphs at the beginning of this film.""" + openingCrawl: String + + """The name of the director of this film.""" + director: String + + """The name(s) of the producer(s) of this film.""" + producers: [String] + + """The ISO 8601 date format of film release at original creator country.""" + releaseDate: String + speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection + starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection + characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection + planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type FilmCharactersConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmCharactersEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + characters: [Person] + } + + """An edge in a connection.""" + type FilmCharactersEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmPlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmPlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] + } + + """An edge in a connection.""" + type FilmPlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type FilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmSpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmSpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] + } + + """An edge in a connection.""" + type FilmSpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type FilmStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type FilmVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } + + """An object with an ID""" + interface Node { + """The id of the object.""" + id: ID! + } + + """Information about pagination in a connection.""" + type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String + } + + """A connection to a list of items.""" + type PeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] + } + + """An edge in a connection.""" + type PeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """An individual person or character within the Star Wars universe.""" + type Person implements Node { + """The name of this person.""" + name: String + + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + + """The height of the person in centimeters.""" + height: Int + + """The mass of the person in kilograms.""" + mass: Float + + """The skin color of this person.""" + skinColor: String + + """A planet that this person was born on or inhabits.""" + homeworld: Planet + filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection + + """The species that this person belongs to, or null if unknown.""" + species: Species + starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type PersonFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type PersonFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PersonStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type PersonStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PersonVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type PersonVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } + + """ + A large mass, planet or planetoid in the Star Wars Universe, at the time of + 0 ABY. + """ + type Planet implements Node { + """The name of this planet.""" + name: String + + """The diameter of this planet in kilometers.""" + diameter: Int + + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + + """The average population of sentient beings inhabiting this planet.""" + population: Float + + """The climates of this planet.""" + climates: [String] + + """The terrains of this planet.""" + terrains: [String] + + """ + The percentage of the planet surface that is naturally occuring water or bodies + of water. + """ + surfaceWater: Float + residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type PlanetFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type PlanetFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PlanetResidentsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetResidentsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + residents: [Person] + } + + """An edge in a connection.""" + type PlanetResidentsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] + } + + """An edge in a connection.""" + type PlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! + } + + type Root { + allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection + film(id: ID, filmID: ID): Film + allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection + person(id: ID, personID: ID): Person + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection + planet(id: ID, planetID: ID): Planet + allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection + species(id: ID, speciesID: ID): Species + allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection + starship(id: ID, starshipID: ID): Starship + allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + + """Fetches an object given its ID""" + node( + """The ID of an object""" + id: ID! + ): Node + } + + """A type of person or character within the Star Wars Universe.""" + type Species implements Node { + """The name of this species.""" + name: String + + """The classification of this species, such as "mammal" or "reptile".""" + classification: String + + """The designation of this species, such as "sentient".""" + designation: String + + """The average height of this species in centimeters.""" + averageHeight: Float + + """The average lifespan of this species in years, null if unknown.""" + averageLifespan: Int + + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + + """The language commonly spoken by this species.""" + language: String + + """A planet that this species originates from.""" + homeworld: Planet + personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection + filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type SpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] + } + + """An edge in a connection.""" + type SpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type SpeciesFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type SpeciesFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type SpeciesPeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesPeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] + } + + """An edge in a connection.""" + type SpeciesPeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A single transport craft that has hyperdrive capability.""" + type Starship implements Node { + """The name of this starship. The common name, such as "Death Star".""" + name: String + + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + + """The manufacturers of this starship.""" + manufacturers: [String] + + """The cost of this starship new, in galactic credits.""" + costInCredits: Float + + """The length of this starship in meters.""" + length: Float + + """The number of personnel needed to run or pilot this starship.""" + crew: String + + """The number of non-essential people this starship can transport.""" + passengers: String + + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + + """The class of this starships hyperdrive.""" + hyperdriveRating: Float + + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + + """The maximum number of kilograms that this starship can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type StarshipFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type StarshipFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type StarshipPilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipPilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] + } + + """An edge in a connection.""" + type StarshipPilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type StarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type StarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A single transport craft that does not have hyperdrive capability""" + type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + + """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" + vehicleClass: String + + """The manufacturers of this vehicle.""" + manufacturers: [String] + + """The cost of this vehicle new, in Galactic Credits.""" + costInCredits: Float + + """The length of this vehicle in meters.""" + length: Float + + """The number of personnel needed to run or pilot this vehicle.""" + crew: String + + """The number of non-essential people this vehicle can transport.""" + passengers: String + + """The maximum speed of this vehicle in atmosphere.""" + maxAtmospheringSpeed: Int + + """The maximum number of kilograms that this vehicle can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type VehicleFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehicleFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type VehicleFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type VehiclePilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclePilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] + } + + """An edge in a connection.""" + type VehiclePilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type VehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type VehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 7b86b4f50e..7282b732b4 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -47,6 +47,13 @@ lighthouse: auth: providers: {} +scaffolder: + github: + token: + $secret: + env: GITHUB_ACCESS_TOKEN + visibility: public # or 'internal' or 'private' + catalog: locations: # Backstage example components diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index ffa10cc3c6..fecef240df 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -7,12 +7,16 @@ import { GithubPublisher, CreateReactAppTemplater, Templaters, + RepoVisilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; -export default async function createPlugin({ logger }: PluginEnvironment) { +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); @@ -26,8 +30,17 @@ export default async function createPlugin({ logger }: PluginEnvironment) { preparers.register('file', filePreparer); preparers.register('github', githubPreparer); - const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); - const publisher = new GithubPublisher({ client: githubClient }); + const githubToken = config.getString('scaffolder.github.token'); + const repoVisibility = config.getString( + 'scaffolder.github.visibility', + ) as RepoVisilityOptions; + + const githubClient = new Octokit({ auth: githubToken }); + const publisher = new GithubPublisher({ + client: githubClient, + token: githubToken, + repoVisibility, + }); const dockerClient = new Docker(); return await createRouter({ diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index bdb7c25c5c..83f92ea360 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -272,6 +272,10 @@ async function createPlugin(pluginName: string, appDir: string) { async function testAppServe(pluginName: string, appDir: string) { const startApp = spawnPiped(['yarn', 'start'], { cwd: appDir, + env: { + ...process.env, + GITHUB_ACCESS_TOKEN: 'abc', + }, }); Browser.localhost('localhost', 3000); @@ -351,6 +355,10 @@ async function testBackendStart(appDir: string, isPostgres: boolean) { const child = spawnPiped(['yarn', 'workspace', 'backend', 'start'], { cwd: appDir, + env: { + ...process.env, + GITHUB_ACCESS_TOKEN: 'abc', + }, }); let stdout = ''; diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index db78b60373..17f354e997 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -14,8 +14,9 @@ The plugin provides a standalone list of APIs, as well as an integration into th Right now, the following API formats are supported: -- [OpenAPI](https://swagger.io/specification/) 2 & 3, -- [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/), +- [OpenAPI](https://swagger.io/specification/) 2 & 3 +- [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/) +- [GraphQL](https://graphql.org/learn/schema/) Other formats are displayed as plain text, but this can easily be extented. diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index a913316925..25e5c6b900 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -29,6 +29,8 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "graphiql": "^1.0.0-alpha.10", + "graphql": "^15.3.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", diff --git a/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx b/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx index f26691d58c..5383fc0570 100644 --- a/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx +++ b/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx @@ -16,7 +16,7 @@ import { ComponentEntity, Entity } from '@backstage/catalog-model'; import { Progress } from '@backstage/core'; -import React, { FC } from 'react'; +import React from 'react'; import { Grid } from '@material-ui/core'; import { ApiDefinitionCard, @@ -24,7 +24,11 @@ import { useComponentApiNames, } from '../../components'; -export const EntityPageApi: FC<{ entity: Entity }> = ({ entity }) => { +type Props = { + entity: Entity; +}; + +export const EntityPageApi = ({ entity }: Props) => { const apiNames = useComponentApiNames(entity as ComponentEntity); const { apiEntities, loading } = useComponentApiEntities({ diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx index 5650b0745f..055363ae1f 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx @@ -15,12 +15,13 @@ */ import { ApiEntity } from '@backstage/catalog-model'; -import { TabbedCard, CardTab } from '@backstage/core'; +import { CardTab, TabbedCard } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; import React from 'react'; import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget'; -import { Alert } from '@material-ui/lab'; import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget'; import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget'; +import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget'; type ApiDefinitionWidget = { type: string; @@ -47,6 +48,14 @@ export function defaultDefinitionWidgets(): ApiDefinitionWidget[] { ), }, + { + type: 'graphql', + title: 'GraphQL', + rawLanguage: 'graphql', + component: definition => ( + + ), + }, ]; } diff --git a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx index aa0df3f20f..b7e9996cc0 100644 --- a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx +++ b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx @@ -34,6 +34,7 @@ import { useAsync } from 'react-use'; import { ApiDefinitionCard } from '../ApiDefinitionCard'; const REDIRECT_DELAY = 1000; + function headerProps( kind: string, namespace: string | undefined, diff --git a/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx new file mode 100644 index 0000000000..7e01df1460 --- /dev/null +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Suspense } from 'react'; +import { buildSchema } from 'graphql'; +import { makeStyles } from '@material-ui/core/styles'; +import { Progress } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; + +const GraphiQL = React.lazy(() => import('graphiql')); + +const useStyles = makeStyles(() => ({ + root: { + height: '100%', + display: 'flex', + flexFlow: 'column nowrap', + }, + graphiQlWrapper: { + flex: 1, + '@global': { + '.graphiql-container': { + boxSizing: 'initial', + height: '100%', + minHeight: '600px', + flex: '1 1 auto', + }, + }, + }, +})); + +type Props = { + definition: any; +}; + +export const GraphQlDefinitionWidget = ({ definition }: Props) => { + const classes = useStyles(); + const schema = buildSchema(definition); + + return ( + }> +
+
+ Promise.resolve(null) as any} + schema={schema} + docExplorerOpen + defaultSecondaryEditorOpen={false} + /> +
+
+
+ ); +}; diff --git a/plugins/jenkins/src/state/index.ts b/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts similarity index 89% rename from plugins/jenkins/src/state/index.ts rename to plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts index d21a380c2a..b60545de15 100644 --- a/plugins/jenkins/src/state/index.ts +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './useBuilds'; -export * from './useBuildWithSteps'; + +export { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget'; diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index a911704dc4..8f0d43cba7 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -14,17 +14,7 @@ Website: [https://jenkins.io/](https://jenkins.io/) yarn add @backstage/plugin-jenkins ``` -2. Add plugin API to your Backstage instance: - -```js -// packages/app/src/api.ts -import { JenkinsApi, jenkinsApiRef } from '@backstage/plugin-jenkins'; - -const builder = ApiRegistry.builder(); -builder.add(jenkinsApiRef, new JenkinsApi(`${backendUrl}/proxy/jenkins/api`)); -``` - -2. Add plugin itself: +2. Add plugin: ```js // packages/app/src/plugins.ts @@ -63,7 +53,7 @@ metadata: name: 'your-component' description: 'a description' annotations: - backstage.io/jenkins-github-folder: 'folder-name/job-name' + jenkins.io/github-folder: 'folder-name/job-name' spec: type: service lifecycle: experimental diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 0d532f8c45..c62df08db2 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -23,6 +23,7 @@ "dependencies": { "@backstage/catalog-model": "^0.1.1-alpha.21", "@backstage/core": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", "@backstage/theme": "^0.1.1-alpha.21", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index 545a1da9cc..2b95fe97d9 100644 --- a/plugins/jenkins/src/api/index.ts +++ b/plugins/jenkins/src/api/index.ts @@ -15,7 +15,7 @@ */ import { createApiRef } from '@backstage/core'; -import { CITableBuildInfo } from '../pages/BuildsPage/lib/CITable'; +import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; const jenkins = require('jenkins'); @@ -65,6 +65,21 @@ export class JenkinsApi { }) .pop(); + const author = jobDetails.actions + .filter( + (action: any) => + action._class === + 'jenkins.scm.api.metadata.ContributorMetadataAction', + ) + .map((action: any) => { + return action.contributorDisplayName; + }) + .pop(); + + if (author) { + scmInfo.author = author; + } + return scmInfo; } @@ -154,12 +169,15 @@ export class JenkinsApi { if (jobScmInfo) { source.url = jobScmInfo?.url; source.displayName = jobScmInfo?.displayName; + source.author = jobScmInfo?.author; } const path = new URL(jenkinsResult.url).pathname; return { id: path, + buildNumber: jenkinsResult.number, + buildUrl: jenkinsResult.url, buildName: jenkinsResult.fullDisplayName, status: jenkinsResult.building ? 'running' : jenkinsResult.result, onRestartClick: () => { diff --git a/plugins/jenkins/src/assets/build-details.png b/plugins/jenkins/src/assets/build-details.png index 396af428af..ee463fea43 100644 Binary files a/plugins/jenkins/src/assets/build-details.png and b/plugins/jenkins/src/assets/build-details.png differ diff --git a/plugins/jenkins/src/assets/folder-results.png b/plugins/jenkins/src/assets/folder-results.png index 2e14f3551f..c01e4cf437 100644 Binary files a/plugins/jenkins/src/assets/folder-results.png and b/plugins/jenkins/src/assets/folder-results.png differ diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx new file mode 100644 index 0000000000..637cd40d6d --- /dev/null +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { Content, Link } from '@backstage/core'; +import { + Typography, + Breadcrumbs, + Paper, + TableContainer, + Table, + TableRow, + TableCell, + TableBody, + Link as MaterialLink, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { useBuildWithSteps } from '../useBuildWithSteps'; +import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity'; +import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; +import ExternalLinkIcon from '@material-ui/icons/Launch'; + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 720, + margin: theme.spacing(2), + }, + table: { + padding: theme.spacing(1), + }, + externalLinkIcon: { + fontSize: 'inherit', + verticalAlign: 'bottom', + }, +})); + +const Page = () => ( + + + +); + +const BuildWithStepsView = () => { + const { owner, repo } = useProjectSlugFromEntity(); + const { branch, buildNumber } = useParams(); + const classes = useStyles(); + const buildPath = `${owner}/${repo}/${branch}/${buildNumber}`; + const [{ value }] = useBuildWithSteps(buildPath); + + return ( +
+ + Jobs + Run + + + + + + + Branch + + {value?.source?.branchName} + + + + Message + + {value?.source?.displayName} + + + + Commit ID + + {value?.source?.commit?.hash} + + + + Status + + + + + + + + Author + + {value?.source?.author} + + + + Jenkins + + + + View on Jenkins{' '} + + + + + + + GitHub + + + + View on GitHub{' '} + + + + + +
+
+
+ ); +}; + +export default Page; +export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts b/plugins/jenkins/src/components/BuildWithStepsPage/index.ts similarity index 100% rename from plugins/jenkins/src/pages/BuildWithStepsPage/index.ts rename to plugins/jenkins/src/components/BuildWithStepsPage/index.ts diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx similarity index 100% rename from plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx rename to plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/index.ts similarity index 100% rename from plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts rename to plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/index.ts diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx similarity index 81% rename from plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx rename to plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 7f17107374..282cbc59b5 100644 --- a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -14,21 +14,26 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { Link, Typography, Box, IconButton } from '@material-ui/core'; +import { Box, IconButton, Link, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; -import { Link as RouterLink } from 'react-router-dom'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; import { Table, TableColumn } from '@backstage/core'; import { JenkinsRunStatus } from '../Status'; +import { useBuilds } from '../../../useBuilds'; +import { useProjectSlugFromEntity } from '../../../useProjectSlugFromEntity'; +import { buildRouteRef } from '../../../../plugin'; export type CITableBuildInfo = { id: string; buildName: string; - buildUrl?: string; + buildNumber: number; + buildUrl: string; source: { branchName: string; url: string; displayName: string; + author?: string; commit: { hash: string; }; @@ -105,7 +110,13 @@ const generatedColumns: TableColumn[] = [ field: 'buildName', highlight: true, render: (row: Partial) => ( - + {row.buildName} ), @@ -177,7 +188,8 @@ type Props = { pageSize: number; onChangePageSize: (pageSize: number) => void; }; -export const CITable: FC = ({ + +export const CITableView: FC = ({ projectName, loading, pageSize, @@ -191,7 +203,7 @@ export const CITable: FC = ({ return ( = ({ onClick: () => retry(), }, ]} - data={builds} + data={builds ?? []} onChangePage={onChangePage} onChangeRowsPerPage={onChangePageSize} title={ @@ -216,3 +228,18 @@ export const CITable: FC = ({ /> ); }; + +export const CITable = () => { + const { owner, repo } = useProjectSlugFromEntity(); + + const [tableProps, { setPage, retry, setPageSize }] = useBuilds(owner, repo); + + return ( + + ); +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts b/plugins/jenkins/src/components/BuildsPage/lib/CITable/index.ts similarity index 100% rename from plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts rename to plugins/jenkins/src/components/BuildsPage/lib/CITable/index.ts diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx b/plugins/jenkins/src/components/BuildsPage/lib/Status/JenkinsRunStatus.tsx similarity index 100% rename from plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx rename to plugins/jenkins/src/components/BuildsPage/lib/Status/JenkinsRunStatus.tsx diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts b/plugins/jenkins/src/components/BuildsPage/lib/Status/index.ts similarity index 100% rename from plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts rename to plugins/jenkins/src/components/BuildsPage/lib/Status/index.ts diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx similarity index 78% rename from plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx rename to plugins/jenkins/src/components/Cards/Cards.tsx index 9bd16bf9e6..5034effdcf 100644 --- a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ import React from 'react'; -import { Entity } from '@backstage/catalog-model'; import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; import { InfoCard, StructuredMetadataTable } from '@backstage/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; -import { useBuilds } from '../../state'; -import { JenkinsRunStatus } from '../../pages/BuildsPage/lib/Status'; +import { useBuilds } from '../useBuilds'; +import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; +import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity'; const useStyles = makeStyles({ externalLinkIcon: { @@ -38,6 +38,7 @@ const WidgetContent = ({ }) => { const classes = useStyles(); if (loading || !lastRun) return ; + return ( { - const [owner, repo] = ( - entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/' - ).split('/'); - const [{ loading, value }] = useBuilds(owner, repo, branch); - - const lastRun = value ?? {}; - +export const LatestRunCard = ({ branch = 'master' }: { branch: string }) => { + const { owner, repo } = useProjectSlugFromEntity(); + const [{ builds, loading }] = useBuilds(owner, repo, branch); + const lastRun = builds ?? {}; return ( diff --git a/plugins/jenkins/src/components/PluginHeader/index.ts b/plugins/jenkins/src/components/Cards/index.ts similarity index 93% rename from plugins/jenkins/src/components/PluginHeader/index.ts rename to plugins/jenkins/src/components/Cards/index.ts index 4de972f6f2..dace09e1a9 100644 --- a/plugins/jenkins/src/components/PluginHeader/index.ts +++ b/plugins/jenkins/src/components/Cards/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './PluginHeader'; +export { LatestRunCard } from './Cards'; diff --git a/plugins/jenkins/src/components/Layout/Layout.tsx b/plugins/jenkins/src/components/Layout/Layout.tsx deleted file mode 100644 index c6000437a3..0000000000 --- a/plugins/jenkins/src/components/Layout/Layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { Header, Page, pageTheme, HeaderLabel } from '@backstage/core'; - -export const Layout: React.FC = ({ children }) => { - return ( - -
- - -
- {children} -
- ); -}; diff --git a/plugins/jenkins/src/components/Layout/index.ts b/plugins/jenkins/src/components/Layout/index.ts deleted file mode 100644 index 236fc98851..0000000000 --- a/plugins/jenkins/src/components/Layout/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from './Layout'; diff --git a/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx b/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx deleted file mode 100644 index 501649e49a..0000000000 --- a/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { ContentHeader, SupportButton } from '@backstage/core'; -import { Box, Typography } from '@material-ui/core'; - -export type Props = { title?: string }; -export const PluginHeader = ({ title = 'Jenkins' }) => { - return ( - ( - - {title} - - )} - > - - This plugin allows you to view and interact with your builds in Jenkins. - - - ); -}; diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx new file mode 100644 index 0000000000..56df5456bc --- /dev/null +++ b/plugins/jenkins/src/components/Router.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Route, Routes } from 'react-router'; +import { buildRouteRef, rootRouteRef } from '../plugin'; +import { DetailedViewPage } from './BuildWithStepsPage/'; +import { JENKINS_ANNOTATION } from '../constants'; +import { Entity } from '@backstage/catalog-model'; +import { WarningPanel } from '@backstage/core'; +import { CITable } from './BuildsPage/lib/CITable'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[JENKINS_ANNOTATION]) && + entity.metadata.annotations?.[JENKINS_ANNOTATION] !== ''; + +export const Router = ({ entity }: { entity: Entity }) => { + return !isPluginApplicableToEntity(entity) ? ( + +
entity.metadata.annotations['{JENKINS_ANNOTATION}']
+ key is missing on the entity. +
+ ) : ( + + } /> + } /> + + ); +}; diff --git a/plugins/jenkins/src/state/useAsyncPolling.ts b/plugins/jenkins/src/components/useAsyncPolling.ts similarity index 100% rename from plugins/jenkins/src/state/useAsyncPolling.ts rename to plugins/jenkins/src/components/useAsyncPolling.ts diff --git a/plugins/jenkins/src/state/useBuildWithSteps.ts b/plugins/jenkins/src/components/useBuildWithSteps.ts similarity index 97% rename from plugins/jenkins/src/state/useBuildWithSteps.ts rename to plugins/jenkins/src/components/useBuildWithSteps.ts index 617b2b1f4f..86d8162612 100644 --- a/plugins/jenkins/src/state/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -16,7 +16,7 @@ import { errorApiRef, useApi } from '@backstage/core'; import { useCallback } from 'react'; import { useAsyncRetry } from 'react-use'; -import { jenkinsApiRef } from '../api/index'; +import { jenkinsApiRef } from '../api'; import { useAsyncPolling } from './useAsyncPolling'; const INTERVAL_AMOUNT = 1500; diff --git a/plugins/jenkins/src/state/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts similarity index 92% rename from plugins/jenkins/src/state/useBuilds.ts rename to plugins/jenkins/src/components/useBuilds.ts index deb3125637..62e3719dbd 100644 --- a/plugins/jenkins/src/state/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -56,8 +56,9 @@ export function useBuilds(owner: string, repo: string, branch?: string) { }); }, [repo, getBuilds]); - const { loading, value, retry } = useAsyncRetry( - () => getBuilds().then(builds => builds ?? [], restartBuild), + const { loading, value: builds, retry } = useAsyncRetry( + () => + getBuilds().then(retrievedBuilds => retrievedBuilds ?? [], restartBuild), [page, pageSize, getBuilds], ); @@ -67,12 +68,12 @@ export function useBuilds(owner: string, repo: string, branch?: string) { page, pageSize, loading, - value, + builds, projectName, total, }, { - getBuilds, + builds, setPage, setPageSize, restartBuild, diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts similarity index 65% rename from plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx rename to plugins/jenkins/src/components/useProjectSlugFromEntity.ts index 469869af69..1dca918990 100644 --- a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx +++ b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts @@ -13,15 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useEntity } from '@backstage/plugin-catalog'; +import { JENKINS_ANNOTATION } from '../constants'; -import React from 'react'; -import { Builds } from '../../pages/BuildsPage/lib/Builds'; -import { Entity } from '@backstage/catalog-model'; +export const useProjectSlugFromEntity = () => { + const { entity } = useEntity(); -export const JenkinsBuildsWidget = ({ entity }: { entity: Entity }) => { const [owner, repo] = ( - entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/' + entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? '' ).split('/'); - - return ; + return { owner, repo }; }; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts b/plugins/jenkins/src/constants.ts similarity index 90% rename from plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts rename to plugins/jenkins/src/constants.ts index e91a9496b7..fe8980de73 100644 --- a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts +++ b/plugins/jenkins/src/constants.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Builds } from './Builds'; +export const JENKINS_ANNOTATION = 'jenkins.io/github-folder'; diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index c7f2d8dc28..30fa0c70a5 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -14,5 +14,8 @@ * limitations under the License. */ -export { plugin, JenkinsBuildsWidget, JenkinsLastBuildWidget } from './plugin'; +export { plugin } from './plugin'; +export { LatestRunCard } from './components/Cards'; +export { Router, isPluginApplicableToEntity } from './components/Router'; +export { JENKINS_ANNOTATION } from './constants'; export * from './api'; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx deleted file mode 100644 index ab4fc3f34f..0000000000 --- a/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { FC, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Content, InfoCard, Progress } from '@backstage/core'; -import { Grid, Box, Link, IconButton } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import { PluginHeader } from '../../components/PluginHeader'; -import { ActionOutput } from './lib/ActionOutput/ActionOutput'; -import { Layout } from '../../components/Layout'; -import LaunchIcon from '@material-ui/icons/Launch'; -import GitHubIcon from '@material-ui/icons/GitHub'; -import { useBuildWithSteps } from '../../state/useBuildWithSteps'; - -const IconLink = IconButton as typeof Link; -const BuildName: FC<{ build?: any }> = ({ build }) => ( - - {build?.buildName} - - {/* TODO use Jenkins logo*/} - - - - - -); -const useStyles = makeStyles(theme => ({ - neutral: {}, - failed: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`, - }, - }, - running: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`, - }, - }, - cardContent: { - backgroundColor: theme.palette.background.default, - }, - success: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`, - }, - }, -})); - -const pickClassName = ( - classes: ReturnType, - build: any = {}, -) => { - if (build.result === 'UNSTABLE') return classes.failed; - if (build.result === 'FAILURE') return classes.failed; - if (build.building) return classes.running; - if (build.status === 'SUCCESS') return classes.success; - - return classes.neutral; -}; - -const Page = () => ( - - - - - -); - -const BuildWithStepsView = () => { - const [searchParams] = useSearchParams(); - const buildPath = searchParams.get('url') || ''; - const classes = useStyles(); - const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps( - buildPath, - ); - - useEffect(() => { - startPolling(); - return () => stopPolling(); - }, [buildPath, startPolling, stopPolling]); - return ( - <> - - - - - } - cardClassName={classes.cardContent} - > - {loading ? : } - - - - - ); -}; - -const BuildsList: FC<{ build?: any }> = ({ build }) => ( - - {build && - build.steps && - build.steps.map(({ name, actions }: { name: string; actions: any[] }) => ( - - ))} - -); - -const ActionsList: FC<{ actions: any[]; name: string }> = ({ actions }) => { - const classes = useStyles(); - return ( - <> - {actions.map((action: any) => ( - - ))} - - ); -}; - -export default Page; -export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx deleted file mode 100644 index 79f081c82d..0000000000 --- a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { CITable } from '../CITable'; -import { useBuilds } from '../../../../state'; - -export const Builds = ({ owner, repo }: { owner: string; repo: string }) => { - const [ - { total, loading, value, projectName, page, pageSize }, - { setPage, retry, setPageSize }, - ] = useBuilds(owner, repo); - return ( - - ); -}; diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 4d1af322c4..8282f6b7f9 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -20,11 +20,15 @@ import { createApiFactory, configApiRef, } from '@backstage/core'; -import { DetailedViewPage } from './pages/BuildWithStepsPage'; import { jenkinsApiRef, JenkinsApi } from './api'; +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Jenkins', +}); + export const buildRouteRef = createRouteRef({ - path: '/jenkins/job', + path: 'run/:branch/:buildNumber', title: 'Jenkins run', }); @@ -40,10 +44,4 @@ export const plugin = createPlugin({ ), }), ], - register({ router }) { - router.addRoute(buildRouteRef, DetailedViewPage); - }, }); - -export { JenkinsBuildsWidget } from './components/JenkinsPluginWidget/JenkinsBuildsWidget'; -export { JenkinsLastBuildWidget } from './components/JenkinsPluginWidget/JenkinsLastBuildWidget'; diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 2283dd5d05..b6f927bc61 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -36,6 +36,7 @@ "git-url-parse": "^11.1.2", "globby": "^11.0.0", "helmet": "^4.0.0", + "jsonschema": "^1.2.6", "morgan": "^1.10.0", "nodegit": "0.26.5", "uuid": "^8.2.0", diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index 86effd0ea5..c06a553771 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -54,50 +54,83 @@ const { }; describe('GitHub Publisher', () => { - const publisher = new GithubPublisher({ client: new Octokit() }); - beforeEach(() => { jest.clearAllMocks(); }); - describe('publish: createRemoteInGithub', () => { - it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'mockclone', - }, - } as OctokitResponse); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as OctokitResponse); + describe('with public repo visibility', () => { + const publisher = new GithubPublisher({ + client: new Octokit(), + token: 'abc', + repoVisibility: 'public', + }); - await publisher.publish({ - values: { - storePath: 'blam/test', - owner: 'bob', - access: 'blam/team', - }, - directory: '/tmp/test', + describe('publish: createRemoteInGithub', () => { + it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { + mockGithubClient.repos.createInOrg.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + + await publisher.publish({ + values: { + storePath: 'blam/test', + owner: 'bob', + access: 'blam/team', + }, + directory: '/tmp/test', + }); + + expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ + org: 'blam', + name: 'test', + private: false, + visibility: 'public', + }); + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg, + ).toHaveBeenCalledWith({ + org: 'blam', + team_slug: 'team', + owner: 'blam', + repo: 'test', + permission: 'admin', + }); }); - expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ - org: 'blam', - name: 'test', - }); - expect( - mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg, - ).toHaveBeenCalledWith({ - org: 'blam', - team_slug: 'team', - owner: 'blam', - repo: 'test', - permission: 'admin', + it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'User', + }, + } as OctokitResponse); + + await publisher.publish({ + values: { + storePath: 'blam/test', + owner: 'bob', + access: 'blam', + }, + directory: '/tmp/test', + }); + + expect( + mockGithubClient.repos.createForAuthenticatedUser, + ).toHaveBeenCalledWith({ + name: 'test', + private: false, + }); + expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); }); }); - it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { + it('should invite other user in the authed user', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'mockclone', @@ -113,7 +146,7 @@ describe('GitHub Publisher', () => { values: { storePath: 'blam/test', owner: 'bob', - access: 'blam', + access: 'bob', }, directory: '/tmp/test', }); @@ -122,151 +155,194 @@ describe('GitHub Publisher', () => { mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ name: 'test', + private: false, + }); + expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({ + owner: 'blam', + repo: 'test', + username: 'bob', + permission: 'admin', }); - expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); }); - }); - it('should invite other user in the authed user', async () => { - mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'mockclone', - }, - } as OctokitResponse); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'User', - }, - } as OctokitResponse); - - await publisher.publish({ - values: { + describe('publish: createGitDirectory', () => { + const values = { storePath: 'blam/test', - owner: 'bob', - access: 'bob', - }, - directory: '/tmp/test', - }); + owner: 'lols', + access: 'lols', + }; - expect( - mockGithubClient.repos.createForAuthenticatedUser, - ).toHaveBeenCalledWith({ - name: 'test', - }); - expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({ - owner: 'blam', - repo: 'test', - username: 'bob', - permission: 'admin', + const mockDir = '/tmp/test/dir'; + + mockGithubClient.repos.createInOrg.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'Organization', + }, + } as OctokitResponse); + + it('should call init on the repo with the directory', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); + }); + + it('should call refresh index on the index and write the new files', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockRepo.refreshIndex).toHaveBeenCalled(); + }); + + it('should call add all files and write', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockIndex.addAll).toHaveBeenCalled(); + expect(mockIndex.write).toHaveBeenCalled(); + expect(mockIndex.writeTree).toHaveBeenCalled(); + }); + + it('should create a commit with on head with the right name and commiter', async () => { + const mockSignature = { mockSignature: 'bloblly' }; + Signature.now.mockReturnValue(mockSignature); + + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Signature.now).toHaveBeenCalledTimes(2); + expect(Signature.now).toHaveBeenCalledWith( + 'Scaffolder', + 'scaffolder@backstage.io', + ); + + expect(mockRepo.createCommit).toHaveBeenCalledWith( + 'HEAD', + mockSignature, + mockSignature, + 'initial commit', + 'mockoid', + [], + ); + }); + + it('creates a remote with the repo and remote', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Remote.create).toHaveBeenCalledWith( + mockRepo, + 'origin', + 'mockclone', + ); + }); + + it('shoud push to the remote repo', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + const [remotes, { callbacks }] = mockRemote.push.mock + .calls[0] as NodeGit.PushOptions[]; + + expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); + + callbacks?.credentials?.(); + + expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + 'abc', + 'x-oauth-basic', + ); + }); }); }); - describe('publish: createGitDirectory', () => { - const values = { - storePath: 'blam/test', - owner: 'lols', - access: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'mockclone', - }, - } as OctokitResponse); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as OctokitResponse); - - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); + describe('with internal repo visibility', () => { + const publisher = new GithubPublisher({ + client: new Octokit(), + token: 'abc', + repoVisibility: 'internal', }); - it('should call refresh index on the index and write the new files', async () => { + it('creates a private repository in the organization with visibility set to internal', async () => { + mockGithubClient.repos.createInOrg.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'Organization', + }, + } as OctokitResponse); + await publisher.publish({ - values, - directory: mockDir, + values: { + isOrg: true, + storePath: 'blam/test', + owner: 'bob', + }, + directory: '/tmp/test', }); - expect(mockRepo.refreshIndex).toHaveBeenCalled(); + expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ + org: 'blam', + name: 'test', + private: true, + visibility: 'internal', + }); + }); + }); + + describe('private visibility in a user account', () => { + const publisher = new GithubPublisher({ + client: new Octokit(), + token: 'abc', + repoVisibility: 'private', }); - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); + it('creates a private repository', async () => { + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'User', + }, + } as OctokitResponse); await publisher.publish({ - values, - directory: mockDir, + values: { + storePath: 'blam/test', + owner: 'bob', + }, + directory: '/tmp/test', }); - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, + expect( + mockGithubClient.repos.createForAuthenticatedUser, + ).toHaveBeenCalledWith({ + name: 'test', + private: true, }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', - 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - process.env.GITHUb_ACCESS_TOKEN = 'blob'; - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( - process.env.GITHUB_ACCESS_TOKEN, - 'x-oauth-basic', - ); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index ab1ffae80e..57a1fca8d9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -21,10 +21,27 @@ import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; import { Repository, Remote, Signature, Cred } from 'nodegit'; +export type RepoVisilityOptions = 'private' | 'internal' | 'public'; + +interface GithubPublisherParams { + client: Octokit; + token: string; + repoVisibility: RepoVisilityOptions; +} + export class GithubPublisher implements PublisherBase { private client: Octokit; - constructor({ client }: { client: Octokit }) { + private token: string; + private repoVisibility: RepoVisilityOptions; + + constructor({ + client, + token, + repoVisibility = 'public', + }: GithubPublisherParams) { this.client = client; + this.token = token; + this.repoVisibility = repoVisibility; } async publish({ @@ -50,8 +67,16 @@ export class GithubPublisher implements PublisherBase { const repoCreationPromise = user.data.type === 'Organization' - ? this.client.repos.createInOrg({ name, org: owner }) - : this.client.repos.createForAuthenticatedUser({ name }); + ? this.client.repos.createInOrg({ + name, + org: owner, + private: this.repoVisibility !== 'public', + visibility: this.repoVisibility, + }) + : this.client.repos.createForAuthenticatedUser({ + name, + private: this.repoVisibility === 'private', + }); const { data } = await repoCreationPromise; @@ -96,10 +121,7 @@ export class GithubPublisher implements PublisherBase { await remoteRepo.push(['refs/heads/master:refs/heads/master'], { callbacks: { credentials: () => { - return Cred.userpassPlaintextNew( - process.env.GITHUB_ACCESS_TOKEN as string, - 'x-oauth-basic', - ); + return Cred.userpassPlaintextNew(this.token, 'x-oauth-basic'); }, }, }); diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts new file mode 100644 index 0000000000..8fd9bca02a --- /dev/null +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { createRouter } from './router'; +import { Templaters, Preparers, PublisherBase } from '../scaffolder'; +import Docker from 'dockerode'; + +jest.mock('dockerode'); + +describe('createRouter', () => { + let app: express.Express; + const publisher: jest.Mocked = { publish: jest.fn() }; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + preparers: new Preparers(), + templaters: new Templaters(), + publisher: publisher, + dockerClient: new Docker(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('POST /v1/jobs', () => { + const template = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + spec: { + owner: 'web@example.com', + path: '.', + schema: { + properties: { + component_id: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + }, + description: { + description: 'Description of the component', + title: 'Description', + type: 'string', + }, + use_typescript: { + default: true, + description: 'Include typescript', + title: 'Use Typescript', + type: 'boolean', + }, + }, + required: ['component_id', 'use_typescript'], + }, + templater: 'cra', + type: 'website', + }, + }; + + it('rejects template values which do not match the template schema definition', async () => { + const response = await request(app).post('/v1/jobs').send({ + template, + values: {}, + }); + + expect(response.status).toEqual(400); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 1b96158fa4..5c67612176 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -28,6 +28,7 @@ import { TemplaterBuilder, PublisherBase, } from '../scaffolder'; +import { validate, ValidatorResult } from 'jsonschema'; export interface RouterOptions { preparers: PreparerBuilder; @@ -84,6 +85,15 @@ export async function createRouter( const values: RequiredTemplateValues & Record = req.body.values; + const validationResult: ValidatorResult = validate( + values, + template.spec.schema, + ); + if (!validationResult.valid) { + res.status(400).json({ errors: validationResult.errors }); + return; + } + const job = jobProcessor.create({ entity: template, values, diff --git a/yarn.lock b/yarn.lock index 38f5d6c799..3da4f288c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13288,6 +13288,11 @@ jsonpointer@^4.0.1: resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" integrity sha1-T9kss04OnbPInIYi7PUfm5eMbLk= +jsonschema@^1.2.6: + version "1.2.6" + resolved "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.6.tgz#52b0a8e9dc06bbae7295249d03e4b9faee8a0c0b" + integrity sha512-SqhURKZG07JyKKeo/ir24QnS4/BV7a6gQy93bUSe4lUdNp0QNpIz2c9elWJQ9dpc5cQYY6cvCzgRwy0MQCLyqA== + jspdf-autotable@3.5.3: version "3.5.3" resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.5.3.tgz#2f73adb07f340e7dbf22950e3e6c8bf853991479"