Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions controller/gettargetgraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ func TestGetTargetGraph_CacheMiss_NoSend(t *testing.T) {
Remote: "repo:go-code",
BaseSha: "sha",
Requests: []*pb.Request{
{Url: "github://repo/1", Commit: "abc111"},
{Url: "github://repo/2", Commit: "abc222"},
{Url: "github://github.com/org/repo/pull/1/1111111111111111111111111111111111111111"},
{Url: "github://github.com/org/repo/pull/2/2222222222222222222222222222222222222222"},
},
},
}
Expand All @@ -83,8 +83,8 @@ func TestGetTargetGraph_StorageError_Propagates(t *testing.T) {
Remote: "repo:go-code",
BaseSha: "sha",
Requests: []*pb.Request{
{Url: "github://repo/1", Commit: "abc111"},
{Url: "github://repo/2", Commit: "abc222"},
{Url: "github://github.com/org/repo/pull/1/1111111111111111111111111111111111111111"},
{Url: "github://github.com/org/repo/pull/2/2222222222222222222222222222222222222222"},
},
},
}, stream)
Expand All @@ -110,8 +110,8 @@ func TestGetTargetGraph_DecodeError_ReturnsError(t *testing.T) {
Remote: "repo:go-code",
BaseSha: "sha",
Requests: []*pb.Request{
{Url: "github://repo/1", Commit: "abc111"},
{Url: "github://repo/2", Commit: "abc222"},
{Url: "github://github.com/org/repo/pull/1/1111111111111111111111111111111111111111"},
{Url: "github://github.com/org/repo/pull/2/2222222222222222222222222222222222222222"},
},
},
}, stream)
Expand Down Expand Up @@ -141,8 +141,8 @@ func TestGetTargetGraph_SendsWhenItemPresent(t *testing.T) {
Remote: "repo:go-code",
BaseSha: "sha",
Requests: []*pb.Request{
{Url: "github://repo/1", Commit: "abc111"},
{Url: "github://repo/2", Commit: "abc222"},
{Url: "github://github.com/org/repo/pull/1/1111111111111111111111111111111111111111"},
{Url: "github://github.com/org/repo/pull/2/2222222222222222222222222222222222222222"},
},
},
}, stream)
Expand All @@ -163,8 +163,8 @@ func TestGetTargetGraph_BuildDescriptionMissingRequiredFields_ReturnsError(t *te
Strategy: pb.COMPUTATION_STRATEGY_UNSET,
Remote: "repo:go-code",
Requests: []*pb.Request{
{Url: "github://repo/1", Commit: "abc111"},
{Url: "github://repo/2", Commit: "abc222"},
{Url: "github://github.com/org/repo/pull/1/1111111111111111111111111111111111111111"},
{Url: "github://github.com/org/repo/pull/2/2222222222222222222222222222222222222222"},
},
},
}, stream)
Expand Down
4 changes: 2 additions & 2 deletions core/cachekey/cachekey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ func TestGetTreehashCachePath(t *testing.T) {
Remote: "git@github:uber/tango",
BaseSha: "deadbeef",
ChangeRequests: []entity.ChangeRequest{
{URL: "github://org/repo/pull/1", Commit: "abc"},
{URL: "custom://foo/bar", Commit: "def"},
{URL: "github://github.com/org/repo/pull/1/1111111111111111111111111111111111111111"},
{URL: "github://github.com/org/repo/pull/2/2222222222222222222222222222222222222222"},
},
}
got := GetTreehashCachePath(desc)
Expand Down
20 changes: 11 additions & 9 deletions core/cachekey/treehash_rapid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,36 @@ import (
"pgregory.net/rapid"
)

func TestGetTreehashCachePath_commitAffectsKey(t *testing.T) {
func TestGetTreehashCachePath_headSHAAffectsKey(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
commit := rapid.StringMatching(`[a-z]+`).Draw(t, "commit")
sha := rapid.StringMatching(`[0-9a-f]{40}`).Draw(t, "sha")
otherSHA := rapid.StringMatching(`[0-9a-f]{40}`).Filter(func(s string) bool { return s != sha }).Draw(t, "otherSHA")
build := entity.BuildDescription{
Remote: "git@github:uber/tango",
BaseSha: "base",
ChangeRequests: []entity.ChangeRequest{
{URL: "github://uber/tango/pull/1", Commit: commit},
{URL: "github://github.com/uber/tango/pull/1/" + sha},
},
}
changed := build
changed.ChangeRequests = append([]entity.ChangeRequest(nil), build.ChangeRequests...)
changed.ChangeRequests[0].Commit = commit + "x"
changed.ChangeRequests = []entity.ChangeRequest{
{URL: "github://github.com/uber/tango/pull/1/" + otherSHA},
}

require.NotEqual(t, GetTreehashCachePath(build), GetTreehashCachePath(changed))
})
}

func TestGetTreehashCachePath_changeRequestOrderAffectsKey(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
firstCommit := rapid.StringMatching(`[a-z]+`).Draw(t, "firstCommit")
secondCommit := rapid.StringMatching(`[a-z]+`).Draw(t, "secondCommit")
firstSHA := rapid.StringMatching(`[0-9a-f]{40}`).Draw(t, "firstSHA")
secondSHA := rapid.StringMatching(`[0-9a-f]{40}`).Filter(func(s string) bool { return s != firstSHA }).Draw(t, "secondSHA")
build := entity.BuildDescription{
Remote: "git@github:uber/tango",
BaseSha: "base",
ChangeRequests: []entity.ChangeRequest{
{URL: "github://uber/tango/pull/1", Commit: firstCommit},
{URL: "github://uber/tango/pull/2", Commit: secondCommit},
{URL: "github://github.com/uber/tango/pull/1/" + firstSHA},
{URL: "github://github.com/uber/tango/pull/2/" + secondSHA},
},
}
swapped := build
Expand Down
19 changes: 19 additions & 0 deletions core/changeuri/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "changeuri",
srcs = ["changeuri.go"],
importpath = "github.com/uber/tango/core/changeuri",
visibility = ["//visibility:public"],
)

go_test(
name = "changeuri_test",
srcs = ["changeuri_test.go"],
embed = [":changeuri"],
deps = [
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
"@net_pgregory_rapid//:rapid",
],
)
181 changes: 181 additions & 0 deletions core/changeuri/changeuri.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// 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.

// Package changeuri parses and validates change URIs — the system-wide
// identity of a code change, per the submitqueue change-URI RFC
// (https://github.com/uber/submitqueue/blob/main/doc/rfc/change-uri.md).
//
// A change URI is an RFC 3986 URI of the form scheme://{host[:port]}/{path}
// whose path pins the change to an exact code state. Only the GitHub pull

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to make future implementations more structured, should we place github implementation under changeuri/github folder? Or even adopt the package from submitqueue?

// request scheme is supported:
//
// github://{host[:port]}/{org}/{repo}/pull/{pr}/{head_sha}
//
// URIs are compared as opaque strings everywhere (cache keys, correlation
// keys), so exactly one spelling per change is valid. Parse validates the
// canonical form and rejects everything else — it never normalizes, because
// normalization applied at one entry point and skipped at another lets two
// spellings of one change into the system.
package changeuri

import (
"fmt"
"net/url"
"strings"
)

// Scheme is the URI scheme for GitHub pull requests.
const Scheme = "github"

// PullRequest is the parsed form of a canonical GitHub pull request URI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the "canonical" Github only has PR number, not a hash I think?

// Re-serializing with String yields the original URI byte-for-byte.
type PullRequest struct {
// Host is the provider instance as host[:port]. The hostname is
// lowercase; the port, when present, is digits only and kept verbatim.
Host string
// Org is the organization path within the instance. It may span
// multiple path segments (e.g. "uber/frontend") and is kept verbatim.
Org string
// Repo is the repository name, kept verbatim.
Repo string
// Number is the pull request number: a positive integer without
// leading zeros, kept as a string to preserve the canonical spelling.
Number string
// HeadSHA is the PR's head commit at submission time: the full
// 40-character lowercase hex form. It pins the exact code state the
// URI identifies.
HeadSHA string
}

// String serializes the pull request back to its canonical URI form.
func (p PullRequest) String() string {
return Scheme + "://" + p.Host + "/" + p.Org + "/" + p.Repo + "/pull/" + p.Number + "/" + p.HeadSHA
}

// Parse validates raw as a canonical GitHub pull request change URI and
// returns its parsed form. Non-canonical spellings (uppercase host,
// abbreviated or uppercase SHA, leading-zero PR number, query, fragment,
// userinfo, empty path segments) are rejected, never normalized.
func Parse(raw string) (PullRequest, error) {
u, err := url.Parse(raw)
if err != nil {
return PullRequest{}, fmt.Errorf("parse change URI: %w", err)
}
if err := validateURL(raw, u); err != nil {
return PullRequest{}, err
}
Comment on lines +71 to +77

@yushan8 yushan8 Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should these lines be in a helper function validateURL. I think it's a little bit more readable that way so this function focuses on parsing the pull request string into the struct.

@xytan0056 xytan0056 Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


// {org…}/{repo}/pull/{pr}/{head_sha}: org spans one or more segments,
// so the layout is anchored from the end of the path.
segments := strings.Split(strings.TrimPrefix(u.EscapedPath(), "/"), "/")
if len(segments) < 5 {
return PullRequest{}, fmt.Errorf("change URI path must be {org}/{repo}/pull/{pr}/{head_sha}: %q", raw)
}
for _, s := range segments {
if s == "" {
return PullRequest{}, fmt.Errorf("change URI path must not contain empty segments: %q", raw)
}
}
n := len(segments)
if segments[n-3] != "pull" {
return PullRequest{}, fmt.Errorf("change URI path must be {org}/{repo}/pull/{pr}/{head_sha}: %q", raw)
}
number, headSHA := segments[n-2], segments[n-1]
if !isCanonicalNumber(number) {
return PullRequest{}, fmt.Errorf("pull request number must be a positive integer without leading zeros, got %q", number)
}
if !isCanonicalSHA(headSHA) {
return PullRequest{}, fmt.Errorf("head SHA must be the full 40-character lowercase hex form, got %q", headSHA)
}

p := PullRequest{
Host: u.Host,
Org: strings.Join(segments[:n-4], "/"),
Repo: segments[n-4],
Number: number,
HeadSHA: headSHA,
}
// Canonical form round-trips byte-for-byte. This catches every
// non-canonical spelling net/url silently tolerates (uppercase scheme,
// percent-encoding variants, default-port cosmetics).
if p.String() != raw {
return PullRequest{}, fmt.Errorf("change URI is not in canonical form: %q", raw)
}
return p, nil
}

// validateURL enforces the URI-level canonical-form rules: the github
// scheme, an authority with a lowercase hostname and an optional
// digits-only port, and no userinfo, query, or fragment.
func validateURL(raw string, u *url.URL) error {
if u.Scheme != Scheme {
return fmt.Errorf("unsupported scheme %q: only %q is supported", u.Scheme, Scheme)
}
if u.Opaque != "" {
return fmt.Errorf("change URI must have an authority: %q", raw)
}
if u.User != nil {
return fmt.Errorf("change URI must not contain userinfo: %q", raw)
}
if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return fmt.Errorf("change URI must not contain a query or fragment: %q", raw)
}
return validateHost(u)
}

// validateHost enforces the RFC host rules: required, lowercase hostname
// (rejected, not folded), and an optional digits-only port kept verbatim.
func validateHost(u *url.URL) error {
if u.Hostname() == "" {
return fmt.Errorf("change URI host is required: %q", u.String())
}
if strings.ToLower(u.Hostname()) != u.Hostname() {
return fmt.Errorf("change URI host must be lowercase: %q", u.Host)
}
// net/url validates that a present port is numeric, but tolerates a
// trailing colon with an empty port, which would round-trip.
if strings.HasSuffix(u.Host, ":") {
return fmt.Errorf("change URI port must not be empty: %q", u.Host)
}
return nil
}

// isCanonicalNumber reports whether s is a positive integer without leading
// zeros.
func isCanonicalNumber(s string) bool {
if s == "" || s[0] < '1' || s[0] > '9' {
return false
}
for i := 1; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}

// isCanonicalSHA reports whether s is a full 40-character lowercase hex
// commit SHA.
func isCanonicalSHA(s string) bool {
if len(s) != 40 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
Loading
Loading