mirror of
https://github.com/google/go-github.git
synced 2026-10-28 16:34:37 +08:00
BREAKING CHANGE: Credentials are sent only to a client's configured API and upload origins — every other destination, including a redirect hop, goes out unauthenticated. A request body aimed outside those origins is refused. `BasicAuthTransport` and `UnauthenticatedRateLimitedTransport` gained `AllowedOrigins`, which GitHub Enterprise users must set.
400 lines
15 KiB
Go
400 lines
15 KiB
Go
// Copyright 2013 The go-github AUTHORS. All rights reserved.
|
|
//
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// Repository contents API methods.
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28
|
|
|
|
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// ErrContentsDirectory indicates that the contents are not available for a directory.
|
|
var ErrContentsDirectory = errors.New("contents not available for directory")
|
|
|
|
// ErrContentsSubmodule indicates that the contents are not available for a submodule.
|
|
var ErrContentsSubmodule = errors.New("contents not available for submodule")
|
|
|
|
// ErrContentsNoDownloadURL indicates that the contents download URL is empty, which may occur when file size > 100 MB.
|
|
var ErrContentsNoDownloadURL = errors.New("contents download url is empty")
|
|
|
|
// RepositoryContent represents a file or directory in a github repository.
|
|
type RepositoryContent struct {
|
|
Type *string `json:"type,omitempty"`
|
|
// Target is only set if the type is "symlink" and the target is not a normal file.
|
|
// If Target is set, Path will be the symlink path.
|
|
Target *string `json:"target,omitempty"`
|
|
Encoding *string `json:"encoding,omitempty"`
|
|
Size *int `json:"size,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
Path *string `json:"path,omitempty"`
|
|
// Content contains the actual file content, which may be encoded.
|
|
// Callers should call GetContent which will decode the content if
|
|
// necessary.
|
|
Content *string `json:"content,omitempty"`
|
|
SHA *string `json:"sha,omitempty"`
|
|
URL *string `json:"url,omitempty"`
|
|
GitURL *string `json:"git_url,omitempty"`
|
|
HTMLURL *string `json:"html_url,omitempty"`
|
|
DownloadURL *string `json:"download_url,omitempty"`
|
|
SubmoduleGitURL *string `json:"submodule_git_url,omitempty"`
|
|
}
|
|
|
|
// RepositoryContentResponse holds the parsed response from CreateFile, UpdateFile, and DeleteFile.
|
|
type RepositoryContentResponse struct {
|
|
Content *RepositoryContent `json:"content,omitempty"`
|
|
Commit `json:"commit"`
|
|
}
|
|
|
|
// RepositoryContentFileOptions specifies optional parameters for CreateFile, UpdateFile, and DeleteFile.
|
|
type RepositoryContentFileOptions struct {
|
|
Message *string `json:"message,omitempty"`
|
|
Content []byte `json:"content"` // unencoded
|
|
SHA *string `json:"sha,omitempty"`
|
|
Branch *string `json:"branch,omitempty"`
|
|
Author *CommitAuthor `json:"author,omitempty"`
|
|
Committer *CommitAuthor `json:"committer,omitempty"`
|
|
}
|
|
|
|
// RepositoryContentGetOptions represents an optional ref parameter, which can be a SHA,
|
|
// branch, or tag. E.g., `6540c41b`, `heads/main`, `tags/v1.0`.
|
|
type RepositoryContentGetOptions struct {
|
|
Ref string `url:"ref,omitempty"`
|
|
}
|
|
|
|
// String converts RepositoryContent to a string. It's primarily for testing.
|
|
func (r RepositoryContent) String() string {
|
|
return Stringify(r)
|
|
}
|
|
|
|
// GetContent returns the content of r, decoding it if necessary.
|
|
func (r *RepositoryContent) GetContent() (string, error) {
|
|
var encoding string
|
|
if r.Encoding != nil {
|
|
encoding = *r.Encoding
|
|
}
|
|
|
|
switch encoding {
|
|
case "base64":
|
|
if r.Content == nil {
|
|
return "", errors.New("malformed response: base64 encoding of null content")
|
|
}
|
|
c, err := base64.StdEncoding.DecodeString(*r.Content)
|
|
return string(c), err
|
|
case "":
|
|
if r.Content == nil {
|
|
return "", nil
|
|
}
|
|
return *r.Content, nil
|
|
case "none":
|
|
return "", errors.New("unsupported content encoding: none, this may occur when file size > 1 MB, if that is the case consider using DownloadContents")
|
|
default:
|
|
return "", fmt.Errorf("unsupported content encoding: %v", encoding)
|
|
}
|
|
}
|
|
|
|
// GetReadme gets the Readme file for the repository.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#get-a-repository-readme
|
|
//
|
|
//meta:operation GET /repos/{owner}/{repo}/readme
|
|
func (s *RepositoriesService) GetReadme(ctx context.Context, owner, repo string, opts *RepositoryContentGetOptions) (*RepositoryContent, *Response, error) {
|
|
u := fmt.Sprintf("repos/%v/%v/readme", owner, repo)
|
|
u, err := addOptions(u, opts)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
req, err := s.client.NewRequest(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var readme *RepositoryContent
|
|
resp, err := s.client.Do(req, &readme)
|
|
if err != nil {
|
|
return nil, resp, err
|
|
}
|
|
|
|
return readme, resp, nil
|
|
}
|
|
|
|
// DownloadContents returns an io.ReadCloser that reads the contents of the
|
|
// specified file. This function will work with files of any size, as opposed
|
|
// to GetContents which is limited to 1 Mb files. It is the caller's
|
|
// responsibility to close the ReadCloser.
|
|
//
|
|
// It is possible for the download to result in a failed response when the
|
|
// returned error is nil. Callers should check the returned Response status
|
|
// code to verify the content is from a successful response.
|
|
//
|
|
// DownloadContents returns [ErrContentsDirectory] if the path references a
|
|
// directory, [ErrContentsSubmodule] if the path references a submodule, and
|
|
// [ErrContentsNoDownloadURL] if the file's download URL is empty.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#get-repository-content
|
|
//
|
|
//meta:operation GET /repos/{owner}/{repo}/contents/{path}
|
|
func (s *RepositoriesService) DownloadContents(ctx context.Context, owner, repo, filepath string, opts *RepositoryContentGetOptions) (io.ReadCloser, *Response, error) {
|
|
rc, _, resp, err := s.DownloadContentsWithMeta(ctx, owner, repo, filepath, opts)
|
|
return rc, resp, err
|
|
}
|
|
|
|
// DownloadContentsWithMeta is identical to DownloadContents but additionally
|
|
// returns the RepositoryContent of the requested file. This additional data
|
|
// is useful for future operations involving the requested file. For merely
|
|
// reading the content of a file, DownloadContents is perfectly adequate.
|
|
//
|
|
// It is possible for the download to result in a failed response when the
|
|
// returned error is nil. Callers should check the returned Response status
|
|
// code to verify the content is from a successful response.
|
|
//
|
|
// DownloadContentsWithMeta returns [ErrContentsDirectory] if the path
|
|
// references a directory, [ErrContentsSubmodule] if the path references a
|
|
// submodule, and [ErrContentsNoDownloadURL] if the file's download URL is
|
|
// empty.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#get-repository-content
|
|
//
|
|
//meta:operation GET /repos/{owner}/{repo}/contents/{path}
|
|
func (s *RepositoriesService) DownloadContentsWithMeta(ctx context.Context, owner, repo, filepath string, opts *RepositoryContentGetOptions) (io.ReadCloser, *RepositoryContent, *Response, error) {
|
|
fileContent, _, resp, err := s.GetContents(ctx, owner, repo, filepath, opts)
|
|
if err != nil {
|
|
return nil, nil, resp, err
|
|
}
|
|
|
|
if fileContent == nil {
|
|
return nil, nil, resp, ErrContentsDirectory
|
|
}
|
|
|
|
if fileContent.GetType() == "submodule" {
|
|
return nil, fileContent, resp, ErrContentsSubmodule
|
|
}
|
|
|
|
content, err := fileContent.GetContent()
|
|
if err == nil && content != "" {
|
|
return io.NopCloser(strings.NewReader(content)), fileContent, resp, nil
|
|
}
|
|
|
|
downloadURL := fileContent.GetDownloadURL()
|
|
if downloadURL == "" {
|
|
return nil, fileContent, resp, ErrContentsNoDownloadURL
|
|
}
|
|
|
|
// download_url is a value the API chose, not the caller, so it is in the same
|
|
// family as a release's UploadURL. The two are not handled the same way, and
|
|
// the difference is what travels: this request carries no body of the
|
|
// caller's, and a download link is cross-origin by design — GitHub serves
|
|
// file content from raw.githubusercontent.com, and asset downloads from a
|
|
// pre-signed CDN host — so refusing a foreign origin here would break the
|
|
// ordinary path rather than a dangerous one. What the rules do cover is the
|
|
// credential: s.client's token is attached only to this client's configured
|
|
// origins, so a link naming another host is fetched without it. The residual
|
|
// is that the bytes come from whatever host the response named; a caller that
|
|
// needs the content to be provably GitHub's should read it from the API
|
|
// response itself.
|
|
dlReq, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
if err != nil {
|
|
return nil, fileContent, resp, err
|
|
}
|
|
|
|
dlResp, err := s.client.client.Do(dlReq)
|
|
if err != nil {
|
|
return nil, fileContent, &Response{Response: dlResp}, err
|
|
}
|
|
|
|
return dlResp.Body, fileContent, &Response{Response: dlResp}, nil
|
|
}
|
|
|
|
// GetContents can return either the metadata and content of a single file
|
|
// (when path references a file) or the metadata of all the files and/or
|
|
// subdirectories of a directory (when path references a directory). To make it
|
|
// easy to distinguish between both result types and to mimic the API as much
|
|
// as possible, both result types will be returned but only one will contain a
|
|
// value and the other will be nil.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#get-repository-content
|
|
//
|
|
//meta:operation GET /repos/{owner}/{repo}/contents/{path}
|
|
func (s *RepositoriesService) GetContents(ctx context.Context, owner, repo, path string, opts *RepositoryContentGetOptions) (fileContent *RepositoryContent, directoryContent []*RepositoryContent, resp *Response, err error) {
|
|
escapedPath := (&url.URL{Path: strings.TrimSuffix(path, "/")}).String()
|
|
u := fmt.Sprintf("repos/%v/%v/contents/%v", owner, repo, escapedPath)
|
|
u, err = addOptions(u, opts)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
req, err := s.client.NewRequest(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
var rawJSON json.RawMessage
|
|
resp, err = s.client.Do(req, &rawJSON)
|
|
if err != nil {
|
|
return nil, nil, resp, err
|
|
}
|
|
|
|
fileUnmarshalError := json.Unmarshal(rawJSON, &fileContent)
|
|
if fileUnmarshalError == nil {
|
|
return fileContent, nil, resp, nil
|
|
}
|
|
|
|
directoryUnmarshalError := json.Unmarshal(rawJSON, &directoryContent)
|
|
if directoryUnmarshalError == nil {
|
|
return nil, directoryContent, resp, nil
|
|
}
|
|
|
|
return nil, nil, resp, fmt.Errorf("unmarshaling failed for both file and directory content: %v and %v", fileUnmarshalError, directoryUnmarshalError)
|
|
}
|
|
|
|
// CreateFile creates a new file in a repository at the given path and returns
|
|
// the commit and file metadata.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents
|
|
//
|
|
//meta:operation PUT /repos/{owner}/{repo}/contents/{path}
|
|
func (s *RepositoriesService) CreateFile(ctx context.Context, owner, repo, path string, body *RepositoryContentFileOptions) (*RepositoryContentResponse, *Response, error) {
|
|
u := fmt.Sprintf("repos/%v/%v/contents/%v", owner, repo, path)
|
|
req, err := s.client.NewRequest(ctx, "PUT", u, body)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var createResponse *RepositoryContentResponse
|
|
resp, err := s.client.Do(req, &createResponse)
|
|
if err != nil {
|
|
return nil, resp, err
|
|
}
|
|
|
|
return createResponse, resp, nil
|
|
}
|
|
|
|
// UpdateFile updates a file in a repository at the given path and returns the
|
|
// commit and file metadata. Requires the blob SHA of the file being updated.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents
|
|
//
|
|
//meta:operation PUT /repos/{owner}/{repo}/contents/{path}
|
|
func (s *RepositoriesService) UpdateFile(ctx context.Context, owner, repo, path string, body *RepositoryContentFileOptions) (*RepositoryContentResponse, *Response, error) {
|
|
u := fmt.Sprintf("repos/%v/%v/contents/%v", owner, repo, path)
|
|
req, err := s.client.NewRequest(ctx, "PUT", u, body)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var updateResponse *RepositoryContentResponse
|
|
resp, err := s.client.Do(req, &updateResponse)
|
|
if err != nil {
|
|
return nil, resp, err
|
|
}
|
|
|
|
return updateResponse, resp, nil
|
|
}
|
|
|
|
// DeleteFile deletes a file from a repository and returns the commit.
|
|
// Requires the blob SHA of the file to be deleted.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#delete-a-file
|
|
//
|
|
//meta:operation DELETE /repos/{owner}/{repo}/contents/{path}
|
|
func (s *RepositoriesService) DeleteFile(ctx context.Context, owner, repo, path string, opts *RepositoryContentFileOptions) (*RepositoryContentResponse, *Response, error) {
|
|
u := fmt.Sprintf("repos/%v/%v/contents/%v", owner, repo, path)
|
|
req, err := s.client.NewRequest(ctx, "DELETE", u, opts)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var deleteResponse *RepositoryContentResponse
|
|
resp, err := s.client.Do(req, &deleteResponse)
|
|
if err != nil {
|
|
return nil, resp, err
|
|
}
|
|
|
|
return deleteResponse, resp, nil
|
|
}
|
|
|
|
// ArchiveFormat is used to define the archive type when calling GetArchiveLink.
|
|
type ArchiveFormat string
|
|
|
|
const (
|
|
// Tarball specifies an archive in gzipped tar format.
|
|
Tarball ArchiveFormat = "tarball"
|
|
|
|
// Zipball specifies an archive in zip format.
|
|
Zipball ArchiveFormat = "zipball"
|
|
)
|
|
|
|
// GetArchiveLink returns an URL to download a tarball or zipball archive for a
|
|
// repository. The archiveFormat can be specified by either the github.Tarball
|
|
// or github.Zipball constant.
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#download-a-repository-archive-tar
|
|
//
|
|
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#download-a-repository-archive-zip
|
|
//
|
|
//meta:operation GET /repos/{owner}/{repo}/tarball/{ref}
|
|
//meta:operation GET /repos/{owner}/{repo}/zipball/{ref}
|
|
func (s *RepositoriesService) GetArchiveLink(ctx context.Context, owner, repo string, archiveformat ArchiveFormat, opts *RepositoryContentGetOptions, maxRedirects int) (*url.URL, *Response, error) {
|
|
u := fmt.Sprintf("repos/%v/%v/%v", owner, repo, archiveformat)
|
|
if opts != nil && opts.Ref != "" {
|
|
u += fmt.Sprintf("/%v", opts.Ref)
|
|
}
|
|
|
|
if s.client.rateLimitRedirectionalEndpoints {
|
|
return s.getArchiveLinkWithRateLimit(ctx, u, maxRedirects)
|
|
}
|
|
|
|
return s.getArchiveLinkWithoutRateLimit(ctx, u, maxRedirects)
|
|
}
|
|
|
|
func (s *RepositoriesService) getArchiveLinkWithoutRateLimit(ctx context.Context, u string, maxRedirects int) (*url.URL, *Response, error) {
|
|
resp, err := s.client.roundTripWithOptionalFollowRedirect(ctx, u, maxRedirects)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusFound {
|
|
return nil, newResponse(resp), fmt.Errorf("unexpected status code: %v", resp.Status)
|
|
}
|
|
|
|
parsedURL, err := url.Parse(resp.Header.Get("Location"))
|
|
if err != nil {
|
|
return nil, newResponse(resp), err
|
|
}
|
|
|
|
return parsedURL, newResponse(resp), nil
|
|
}
|
|
|
|
func (s *RepositoriesService) getArchiveLinkWithRateLimit(ctx context.Context, u string, maxRedirects int) (*url.URL, *Response, error) {
|
|
req, err := s.client.NewRequest(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
url, resp, err := s.client.bareDoUntilFound(req, maxRedirects)
|
|
if err != nil {
|
|
return nil, resp, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// If we didn't receive a valid Location in a 302 response
|
|
if url == nil {
|
|
return nil, resp, fmt.Errorf("unexpected status code: %v", resp.Status)
|
|
}
|
|
|
|
return url, resp, nil
|
|
}
|