Skip to content
Closed
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
67 changes: 67 additions & 0 deletions .github/workflows/conflict-check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Check for unresolved conflicts

on:
pull_request:
branches:
- 'v**'

jobs:
conflict-check:
runs-on: ubuntu-latest
name: Check for conflict markers
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4

- name: Find conflict markers
id: conflicts
run: |
# Look for git conflict markers: <<<<<<< followed by branch name
# Exclude vendor, node_modules, and common non-source directories
CONFLICTS=$(grep -rn "^<<<<<<< " \
--include="*.go" --include="*.yaml" --include="*.yml" --include="*.json" \
--include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \
--include="*.py" --include="*.rb" --include="*.rs" --include="*.sh" \
--include="*.md" --include="*.txt" \
--exclude-dir=vendor --exclude-dir=node_modules --exclude-dir=.git \
. 2>/dev/null || true)

if [ -n "$CONFLICTS" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
echo "### Conflict markers found:"
echo "$CONFLICTS"

# Save to file for PR comment
{
echo "## Unresolved Merge Conflicts Detected"
echo ""
echo "This PR contains unresolved merge conflict markers. Please resolve them before merging."
echo ""
echo "### Conflicted Files"
echo ""
echo '```'
echo "$CONFLICTS"
echo '```'
} > /tmp/conflict-report.md
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No conflict markers found"
fi

- name: Comment on PR
if: steps.conflicts.outputs.found == 'true' && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Update existing comment or create new one
gh pr comment "$PR_NUMBER" --body-file /tmp/conflict-report.md --edit-last --create-if-none 2>/dev/null || \
gh pr comment "$PR_NUMBER" --body-file /tmp/conflict-report.md

- name: Fail if conflicts found
if: steps.conflicts.outputs.found == 'true'
run: |
echo "::error::Unresolved merge conflict markers found"
exit 1
87 changes: 87 additions & 0 deletions hack/linear-sync/linear_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,90 @@ func TestIssueIDsExtraction(t *testing.T) {
})
}
}

func TestIsStableRelease(t *testing.T) {
testCases := []struct {
version string
expected bool
}{
// Stable releases
{"v0.26.1", true},
{"v4.5.0", true},
{"v1.0.0", true},
{"0.26.1", true}, // without v prefix
{"v27.0.0", true},

// Pre-releases
{"v0.26.1-alpha.1", false},
{"v0.26.1-alpha.5", false},
{"v0.26.1-beta.1", false},
{"v0.26.1-rc.1", false},
{"v0.26.1-rc.4", false},
{"v0.26.1-dev.1", false},
{"v0.26.1-pre.1", false},
{"v0.26.1-next.1", false},
{"v4.5.0-beta.2", false},
{"0.27.0-alpha.1", false}, // without v prefix
}

for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
result := isStableRelease(tc.version)
if result != tc.expected {
t.Errorf("isStableRelease(%q) = %v, want %v", tc.version, result, tc.expected)
}
})
}
}

func TestStableReleaseCommentText(t *testing.T) {
// Test the comment text logic for different scenarios
testCases := []struct {
name string
alreadyReleased bool
isStable bool
releaseTag string
releaseDate string
expectedContains string
}{
{
name: "First release (pre-release)",
alreadyReleased: false,
isStable: false,
releaseTag: "v0.27.0-alpha.1",
releaseDate: "2025-01-15",
expectedContains: "first released in",
},
{
name: "First release (stable)",
alreadyReleased: false,
isStable: true,
releaseTag: "v0.27.0",
releaseDate: "2025-02-01",
expectedContains: "first released in",
},
{
name: "Stable release on already-released issue",
alreadyReleased: true,
isStable: true,
releaseTag: "v0.27.0",
releaseDate: "2025-02-01",
expectedContains: "Now available in stable release",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var releaseComment string
if tc.alreadyReleased && tc.isStable {
releaseComment = fmt.Sprintf("Now available in stable release %v (released %v)", tc.releaseTag, tc.releaseDate)
} else {
releaseComment = fmt.Sprintf("This issue was first released in %v on %v", tc.releaseTag, tc.releaseDate)
}

if !strings.Contains(releaseComment, tc.expectedContains) {
t.Errorf("Comment %q does not contain expected text %q", releaseComment, tc.expectedContains)
}
})
}
}
64 changes: 47 additions & 17 deletions hack/linear-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ var (
ErrMissingReleaseTag = errors.New("release tag must be set")
)

var LoggerKey struct{}
var LoggerKey = struct{ name string }{"logger"}

func main() {
if err := run(context.Background(), io.Writer(os.Stderr), os.Args); err != nil {
Expand All @@ -47,8 +47,8 @@ func run(
linearToken = flagset.String("linear-token", "", "The Linear token to use for authentication")
releasedStateName = flagset.String("released-state-name", "Released", "The name of the state to use for the released state")
readyForReleaseStateName = flagset.String("ready-for-release-state-name", "Ready for Release", "The name of the state that indicates an issue is ready to be released")
linearTeamName = flagset.String("linear-team-name", "vCluster / Platform", "The name of the team to use for the linear team")
dryRun = flagset.Bool("dry-run", false, "Do not actually move issues to the released state")
strictFiltering = flagset.Bool("strict-filtering", true, "Only include PRs that were actually merged before the release was published (recommended to avoid false positives)")
)
if err := flagset.Parse(args[1:]); err != nil {
return fmt.Errorf("parse flags: %w", err)
Expand Down Expand Up @@ -139,9 +139,20 @@ func run(
return fmt.Errorf("fetch all PRs until: %w", err)
}

pullRequests := NewLinearPullRequests(prs)

logger.Info("Found merged pull requests between releases", "count", len(pullRequests), "previous", stableTag, "current", *releaseTag)
var pullRequests []LinearPullRequest
if *strictFiltering {
// Filter PRs to only include those that were actually part of this release
filteredPRs, err := pullrequests.FetchPRsForRelease(ctx, gqlClient, *owner, *repo, stableTag, *releaseTag, currentRelease.PublishedAt.Time)
if err != nil {
return fmt.Errorf("filter PRs for release: %w", err)
}
pullRequests = NewLinearPullRequests(filteredPRs)
logger.Info("Found merged pull requests for release", "total", len(prs), "filtered", len(pullRequests), "previous", stableTag, "current", *releaseTag)
} else {
// Use all PRs between tags (original behavior)
pullRequests = NewLinearPullRequests(prs)
logger.Info("Found merged pull requests between releases", "count", len(pullRequests), "previous", stableTag, "current", *releaseTag)
}

releasedIssues := []string{}

Expand All @@ -162,27 +173,46 @@ func run(

linearClient := NewLinearClient(ctx, *linearToken)

releasedStateID, err := linearClient.WorkflowStateID(ctx, *releasedStateName, *linearTeamName)
if err != nil {
return fmt.Errorf("get released workflow ID: %w", err)
}

logger.Debug("Found released workflow ID", "workflowID", releasedStateID)
// Cache of team name -> released state ID (looked up on demand)
releasedStateIDByTeam := make(map[string]string)

readyForReleaseStateID, err := linearClient.WorkflowStateID(ctx, *readyForReleaseStateName, *linearTeamName)
if err != nil {
return fmt.Errorf("get ready for release workflow ID: %w", err)
// Helper to get released state ID for a team (with caching)
getReleasedStateID := func(teamName string) (string, error) {
if stateID, ok := releasedStateIDByTeam[teamName]; ok {
return stateID, nil
}
stateID, err := linearClient.WorkflowStateID(ctx, *releasedStateName, teamName)
if err != nil {
return "", err
}
releasedStateIDByTeam[teamName] = stateID
logger.Debug("Found released workflow ID for team", "team", teamName, "workflowID", stateID)
return stateID, nil
}

logger.Debug("Found ready for release workflow ID", "workflowID", readyForReleaseStateID)

currentReleaseDateStr := currentRelease.PublishedAt.Format("2006-01-02")

releasedCount := 0
skippedCount := 0

for _, issueID := range releasedIssues {
if err := linearClient.MoveIssueToState(ctx, *dryRun, issueID, releasedStateID, *readyForReleaseStateName, currentRelease.TagName, currentReleaseDateStr); err != nil {
// Get issue details including team
issueDetails, err := linearClient.GetIssueDetails(ctx, issueID)
if err != nil {
logger.Error("Failed to get issue details", "issueID", issueID, "error", err)
skippedCount++
continue
}

// Get the released state ID for this issue's team
releasedStateID, err := getReleasedStateID(issueDetails.TeamName)
if err != nil {
logger.Error("Failed to get released state for team", "issueID", issueID, "team", issueDetails.TeamName, "error", err)
skippedCount++
continue
}

if err := linearClient.MoveIssueToState(ctx, *dryRun, issueID, issueDetails, releasedStateID, *readyForReleaseStateName, currentRelease.TagName, currentReleaseDateStr); err != nil {
logger.Error("Failed to move issue to state", "issueID", issueID, "error", err)
skippedCount++
} else {
Expand Down
Loading