diff --git a/.github/workflows/nixpkgs-bump.yml b/.github/workflows/nixpkgs-bump.yml index c96cd932..473df935 100644 --- a/.github/workflows/nixpkgs-bump.yml +++ b/.github/workflows/nixpkgs-bump.yml @@ -1,16 +1,15 @@ name: Nixpkgs Bump PR -# Triggers on stable release publish. Opens PR against NixOS/nixpkgs -# bumping pkgs/by-name/ma/matcha/package.nix to the new version. +# Manual dispatch only. r-ryantm bot handles automated bumps in nixpkgs. +# This workflow exists for emergency / out-of-band bumps. +# Uses whatever Go version is current on nixpkgs master. # Requires: # - Fork floatpane/nixpkgs to exist -# - NIXPKGS_BUMP_TOKEN secret: PAT with `repo` scope on floatpane/nixpkgs +# - HOMEBREW_GITHUB_TOKEN secret: PAT with `repo` scope on floatpane/nixpkgs # and permission to open PRs against NixOS/nixpkgs -# - Initial matcha package already merged into nixpkgs (this workflow updates, not inits) +# - Initial matcha package already merged into nixpkgs on: - release: - types: [published] workflow_dispatch: inputs: version: @@ -24,33 +23,18 @@ jobs: bump: runs-on: ubuntu-latest steps: - - name: Determine version - id: ver - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ inputs.version }}" - else - TAG="${{ github.event.release.tag_name }}" - VERSION="${TAG#v}" - fi - # Skip nightly / preview tags - if [[ "$VERSION" == nightly* || "$VERSION" == preview* ]]; then - echo "Skipping non-stable release: $VERSION" - echo "skip=true" >> $GITHUB_OUTPUT - else - echo "skip=false" >> $GITHUB_OUTPUT - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Install Nix - if: steps.ver.outputs.skip != 'true' uses: cachix/install-nix-action@v31 with: extra_nix_config: | experimental-features = nix-command flakes + - name: Checkout matcha (source of truth for package.nix) + uses: actions/checkout@v6 + with: + path: matcha + - name: Checkout nixpkgs fork - if: steps.ver.outputs.skip != 'true' uses: actions/checkout@v6 with: repository: floatpane/nixpkgs @@ -59,19 +43,17 @@ jobs: fetch-depth: 0 - name: Sync fork with upstream master - if: steps.ver.outputs.skip != 'true' working-directory: nixpkgs run: | git config user.name "Floatpane Bot" git config user.email "us@floatpane.com" git remote add upstream https://github.com/NixOS/nixpkgs.git - git fetch upstream master staging + git fetch upstream master git checkout master git reset --hard upstream/master git push origin master --force-with-lease - name: Get current version (from master) - if: steps.ver.outputs.skip != 'true' id: current working-directory: nixpkgs run: | @@ -79,54 +61,32 @@ jobs: OLD=$(grep -E '^\s*version\s*=\s*"' "$PKG" | head -1 | sed -E 's/.*"([^"]+)".*/\1/') echo "old=$OLD" >> $GITHUB_OUTPUT - - name: Write go overlay from staging - if: steps.ver.outputs.skip != 'true' - working-directory: nixpkgs - run: | - # master nixpkgs heavily cached. Staging has go_1_26 = 1.26.3. - # Overlay swaps only go_1_26 → minimal rebuild. - STAGING_REV=$(git rev-parse upstream/staging) - echo "STAGING_REV=$STAGING_REV" >> $GITHUB_ENV - cat > /tmp/go-overlay.nix <> $GITHUB_ENV - - name: Bump version and reset hashes - if: steps.ver.outputs.skip != 'true' + - name: Sync package.nix from matcha repo and bump version working-directory: nixpkgs run: | PKG=pkgs/by-name/ma/matcha/package.nix - NEW="${{ steps.ver.outputs.version }}" - # Replace version line + NEW="${{ inputs.version }}" + # Overwrite nixpkgs package.nix with matcha repo's template. + # Template is source of truth; nixpkgs PR mirrors it. + cp ../matcha/nix/nixpkgs-package.nix "$PKG" sed -i -E "s/(version\s*=\s*\")[^\"]+(\")/\1$NEW\2/" "$PKG" - # Reset src hash + vendorHash to fakeHash so nix build prints real ones + # Hashes already fakeHash in template; explicit reset for safety. sed -i -E 's|hash = "sha256-[A-Za-z0-9+/=]+"|hash = lib.fakeHash|' "$PKG" sed -i -E 's|vendorHash = "sha256-[A-Za-z0-9+/=]+"|vendorHash = lib.fakeHash|' "$PKG" - name: Prefetch src hash (no build) - if: steps.ver.outputs.skip != 'true' id: src_hash working-directory: nixpkgs run: | - NEW="${{ steps.ver.outputs.version }}" + NEW="${{ inputs.version }}" URL="https://github.com/floatpane/matcha/archive/refs/tags/v$NEW.tar.gz" - # --unpack matches fetchFromGitHub (NAR hash of unpacked tarball) BASE32=$(nix-prefetch-url --unpack "$URL") HASH=$(nix hash to-sri --type sha256 "$BASE32") echo "Resolved SRI hash: $HASH" @@ -134,13 +94,10 @@ jobs: sed -i -E "s|hash = lib.fakeHash|hash = \"$HASH\"|" pkgs/by-name/ma/matcha/package.nix - name: Build to extract vendorHash - if: steps.ver.outputs.skip != 'true' working-directory: nixpkgs run: | set +e - nix-build ./. -A matcha --no-out-link \ - --arg overlays "[ (import /tmp/go-overlay.nix) ]" \ - 2>&1 | tee /tmp/build-vendor.log + nix-build ./. -A matcha --no-out-link 2>&1 | tee /tmp/build-vendor.log HASH=$(grep -oE 'got:[[:space:]]+sha256-[A-Za-z0-9+/=]+' /tmp/build-vendor.log | head -1 | awk '{print $2}') if [ -z "$HASH" ]; then echo "Failed to extract vendorHash"; exit 1 @@ -148,22 +105,18 @@ jobs: sed -i -E "s|vendorHash = lib.fakeHash|vendorHash = \"$HASH\"|" pkgs/by-name/ma/matcha/package.nix - name: Final build (sanity check) - if: steps.ver.outputs.skip != 'true' working-directory: nixpkgs run: | - nix-build ./. -A matcha --no-out-link \ - --arg overlays "[ (import /tmp/go-overlay.nix) ]" + nix-build ./. -A matcha --no-out-link - name: Commit and push - if: steps.ver.outputs.skip != 'true' working-directory: nixpkgs run: | git add pkgs/by-name/ma/matcha/package.nix - git commit -m "matcha: ${{ steps.current.outputs.old }} -> ${{ steps.ver.outputs.version }}" + git commit -m "matcha: ${{ steps.current.outputs.old }} -> ${{ inputs.version }}" git push -u origin "$BRANCH" --force-with-lease - name: Open PR against NixOS/nixpkgs - if: steps.ver.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} working-directory: nixpkgs @@ -171,17 +124,17 @@ jobs: BODY=$(cat < ${{ steps.ver.outputs.version }}" \ + --title "matcha: ${{ steps.current.outputs.old }} -> ${{ inputs.version }}" \ --body "$BODY" diff --git a/.github/workflows/sync-go-nixpkgs.yml b/.github/workflows/sync-go-nixpkgs.yml new file mode 100644 index 00000000..cac4063a --- /dev/null +++ b/.github/workflows/sync-go-nixpkgs.yml @@ -0,0 +1,86 @@ +name: Sync go.mod minimum from nixpkgs master + +# Renovate manages `toolchain` directive (preferred Go). +# This workflow manages `go` directive (minimum Go) to track nixpkgs master. +# Keeps r-ryantm / nixpkgs sandbox builds passing. + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + + - uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Get nixpkgs master Go version + id: nixgo + run: | + VER=$(nix eval --raw --impure --expr \ + '(import (builtins.fetchTarball "https://github.com/NixOS/nixpkgs/archive/master.tar.gz") {}).go_1_26.version') + echo "version=$VER" + echo "version=$VER" >> $GITHUB_OUTPUT + + - name: Get current go.mod values + id: current + run: | + GO=$(grep -E '^go [0-9.]+$' go.mod | awk '{print $2}') + TC=$(grep -E '^toolchain go[0-9.]+$' go.mod | sed 's/toolchain go//') + echo "go=$GO" >> $GITHUB_OUTPUT + echo "toolchain=$TC" >> $GITHUB_OUTPUT + + - name: Bump go.mod if needed + id: bump + run: | + NEW="${{ steps.nixgo.outputs.version }}" + GO="${{ steps.current.outputs.go }}" + TC="${{ steps.current.outputs.toolchain }}" + CHANGED=false + if [ "$GO" != "$NEW" ]; then + sed -i -E "s/^go [0-9.]+$/go $NEW/" go.mod + CHANGED=true + fi + # If toolchain now < go minimum, raise toolchain to match + if [ -n "$TC" ]; then + LOWER=$(printf '%s\n%s\n' "$TC" "$NEW" | sort -V | head -1) + if [ "$LOWER" = "$TC" ] && [ "$TC" != "$NEW" ]; then + sed -i -E "s/^toolchain go[0-9.]+$/toolchain go$NEW/" go.mod + CHANGED=true + fi + fi + echo "changed=$CHANGED" >> $GITHUB_OUTPUT + echo "old=$GO" >> $GITHUB_OUTPUT + echo "new=$NEW" >> $GITHUB_OUTPUT + + - name: Open PR + if: steps.bump.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + committer: "Floatpane Bot " + author: "Floatpane Bot " + commit-message: "chore: bump go.mod minimum to ${{ steps.bump.outputs.new }} (nixpkgs master)" + branch: sync-go-nixpkgs + delete-branch: true + title: "chore: bump go.mod minimum to ${{ steps.bump.outputs.new }}" + body: | + nixpkgs master ships Go `${{ steps.bump.outputs.new }}`. + Bump `go` directive in `go.mod` to match (was `${{ steps.bump.outputs.old }}`). + + Keeps r-ryantm / nixpkgs sandbox builds passing. + `toolchain` directive raised only if it fell below the new minimum. + labels: | + dependencies diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index c9f4ca6a..a883cf34 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -188,25 +188,8 @@ func (p *Provider) MarkAsUnread(_ context.Context, _ string, _ uint32) error { return nil } -func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error { - conn, err := p.connect() - if err != nil { - return err - } - - msgID, err := p.findMessageByUID(conn, uid) - if err != nil { - conn.Quit() - return err - } - - if err := conn.Dele(msgID); err != nil { - conn.Quit() - return fmt.Errorf("pop3 dele: %w", err) - } - - // Quit commits the deletion - return conn.Quit() +func (p *Provider) DeleteEmail(ctx context.Context, folder string, uid uint32) error { + return p.DeleteEmails(ctx, folder, []uint32{uid}) } func (p *Provider) ArchiveEmail(_ context.Context, _ string, _ uint32) error { @@ -217,14 +200,34 @@ func (p *Provider) MoveEmail(_ context.Context, _ uint32, _, _ string) error { return backend.ErrNotSupported } -func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error { - // POP3 doesn't support batch - loop through individual operations +func (p *Provider) DeleteEmails(_ context.Context, _ string, uids []uint32) error { + if len(uids) == 0 { + return nil + } + + conn, err := p.connect() + if err != nil { + return err + } + + messageIDsByUID, err := p.buildMessageIDsByUID(conn) + if err != nil { + conn.Quit() + return err + } + for _, uid := range uids { - if err := p.DeleteEmail(ctx, folder, uid); err != nil { - return err + msgID, ok := messageIDsByUID[uid] + if !ok { + return fmt.Errorf("pop3: message with UID %d not found", uid) + } + + if err := conn.Dele(msgID); err != nil { + return fmt.Errorf("pop3 dele: %w", err) } } - return nil + + return conn.Quit() } func (p *Provider) ArchiveEmails(_ context.Context, _ string, _ []uint32) error { @@ -261,31 +264,40 @@ func (p *Provider) Close() error { return nil } -// Verify interface compliance at compile time. -var _ backend.Provider = (*Provider)(nil) - -// findMessageByUID finds a POP3 message ID by matching the UID hash. -func (p *Provider) findMessageByUID(conn *pop3client.Conn, uid uint32) (int, error) { +func (p *Provider) buildMessageIDsByUID(conn *pop3client.Conn) (map[uint32]int, error) { msgs, err := conn.Uidl(0) if err != nil { msgs, err = conn.List(0) if err != nil { - return 0, fmt.Errorf("pop3 list: %w", err) + return nil, fmt.Errorf("pop3 list: %w", err) } + + messageIDsByUID := make(map[uint32]int, len(msgs)) for _, m := range msgs { - if hashUID(fmt.Sprintf("%d", m.ID)) == uid { - return m.ID, nil - } + messageIDsByUID[hashUID(fmt.Sprintf("%d", m.ID))] = m.ID } - return 0, fmt.Errorf("pop3: message with UID %d not found", uid) + return messageIDsByUID, nil } + messageIDsByUID := make(map[uint32]int, len(msgs)) for _, m := range msgs { - if hashUID(m.UID) == uid { - return m.ID, nil - } + messageIDsByUID[hashUID(m.UID)] = m.ID } - return 0, fmt.Errorf("pop3: message with UID %d not found", uid) + return messageIDsByUID, nil +} + +// findMessageByUID finds a POP3 message ID by matching the UID hash. +func (p *Provider) findMessageByUID(conn *pop3client.Conn, uid uint32) (int, error) { + messageIDsByUID, err := p.buildMessageIDsByUID(conn) + if err != nil { + return 0, err + } + + msgID, ok := messageIDsByUID[uid] + if !ok { + return 0, fmt.Errorf("pop3: message with UID %d not found", uid) + } + return msgID, nil } // hashUID converts a POP3 UIDL string to a uint32 hash. @@ -300,6 +312,9 @@ func hashUID(uidl string) uint32 { return hash } +// Verify interface compliance at compile time. +var _ backend.Provider = (*Provider)(nil) + // entityToEmail converts message headers to a backend.Email. func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, accountID string) backend.Email { from := header.Get("From") diff --git a/calendar/calendar.go b/calendar/calendar.go index c3d27c0f..1a02deaa 100644 --- a/calendar/calendar.go +++ b/calendar/calendar.go @@ -147,10 +147,18 @@ func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.T value := p.Value var tzid string + var isDateOnly bool if params := p.ICalParameters; params != nil { if tzids := params["TZID"]; len(tzids) > 0 { tzid = tzids[0] } + if vals := params["VALUE"]; len(vals) > 0 && strings.EqualFold(vals[0], "DATE") { + isDateOnly = true + } + } + // RFC 5545 DATE form is YYYYMMDD (8 chars, no time component). + if !isDateOnly && len(value) == 8 { + isDateOnly = true } // Try parsing with timezone @@ -176,8 +184,9 @@ func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.T return time.Time{}, fmt.Errorf("parse timestamp: %w", err) } - // Apply timezone if specified - if tzid != "" && !strings.HasSuffix(value, "Z") { + // Apply timezone if specified. RFC 5545: VALUE=DATE has no timezone, so + // TZID must be ignored for date-only values even when present. + if tzid != "" && !strings.HasSuffix(value, "Z") && !isDateOnly { if loc, locErr := time.LoadLocation(tzid); locErr == nil { t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc) } diff --git a/calendar/calendar_test.go b/calendar/calendar_test.go index 4d7da3ef..71554dfa 100644 --- a/calendar/calendar_test.go +++ b/calendar/calendar_test.go @@ -129,6 +129,91 @@ func TestGenerateRSVP(t *testing.T) { } } +// buildICS wraps DTSTART/DTEND lines into a minimal VCALENDAR for ParseICS. +func buildICS(dtstart, dtend string) []byte { + return []byte("BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//Test//Test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:date-only@example.com\r\n" + + "DTSTAMP:20260415T120000Z\r\n" + + dtstart + "\r\n" + + dtend + "\r\n" + + "SUMMARY:Test\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n") +} + +func TestParseICS_DateOnly(t *testing.T) { + wantStart := time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC) + wantEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + dtstart string + dtend string + }{ + { + name: "VALUE=DATE without TZID", + dtstart: "DTSTART;VALUE=DATE:20260421", + dtend: "DTEND;VALUE=DATE:20260422", + }, + { + // Regression: TZID present on a date-only value must be ignored + // (RFC 5545 forbids TZID with VALUE=DATE; some producers emit it anyway). + name: "VALUE=DATE with TZID is ignored", + dtstart: "DTSTART;TZID=America/New_York;VALUE=DATE:20260421", + dtend: "DTEND;TZID=America/New_York;VALUE=DATE:20260422", + }, + { + // Shape-only detection: no VALUE param, but YYYYMMDD value with TZID. + name: "YYYYMMDD shape with TZID is treated as date-only", + dtstart: "DTSTART;TZID=America/Los_Angeles:20260421", + dtend: "DTEND;TZID=America/Los_Angeles:20260422", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := ParseICS(buildICS(tt.dtstart, tt.dtend)) + if err != nil { + t.Fatalf("ParseICS failed: %v", err) + } + if !event.Start.Equal(wantStart) { + t.Errorf("Start = %v, want %v", event.Start.UTC(), wantStart) + } + if !event.End.Equal(wantEnd) { + t.Errorf("End = %v, want %v", event.End.UTC(), wantEnd) + } + }) + } +} + +func TestParseICS_TimedWithTZID(t *testing.T) { + // Existing behavior: timed values with TZID keep their zone semantics. + event, err := ParseICS(buildICS( + "DTSTART;TZID=America/New_York:20260421T140000", + "DTEND;TZID=America/New_York:20260421T153000", + )) + if err != nil { + t.Fatalf("ParseICS failed: %v", err) + } + + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skipf("America/New_York unavailable on this system: %v", err) + } + wantStart := time.Date(2026, 4, 21, 14, 0, 0, 0, loc) + wantEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, loc) + + if !event.Start.Equal(wantStart) { + t.Errorf("Start = %v, want %v", event.Start, wantStart) + } + if !event.End.Equal(wantEnd) { + t.Errorf("End = %v, want %v", event.End, wantEnd) + } +} + func TestExtractEmail(t *testing.T) { tests := []struct { input string diff --git a/go.mod b/go.mod index b4ce7af8..76a869d6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/floatpane/matcha -go 1.26.3 +go 1.26.2 + +toolchain go1.26.3 require ( charm.land/bubbles/v2 v2.1.0 diff --git a/nix/nixpkgs-package.nix b/nix/nixpkgs-package.nix index dd9b47c0..28fa0cfc 100644 --- a/nix/nixpkgs-package.nix +++ b/nix/nixpkgs-package.nix @@ -2,6 +2,7 @@ lib, buildGoModule, fetchFromGitHub, + go, pkg-config, pcsclite, stdenv, @@ -26,6 +27,12 @@ buildGoModule (finalAttrs: { vendorHash = lib.fakeHash; proxyVendor = true; + # Upstream pins `toolchain` to latest Go for dev builds (GOTOOLCHAIN=auto). + # Nix sandbox sets GOTOOLCHAIN=local and can't download — rewrite to nixpkgs Go. + postPatch = '' + sed -i -E "s/^toolchain go[0-9.]+$/toolchain go${go.version}/" go.mod + ''; + nativeBuildInputs = lib.optionals stdenv.hostPlatform.isLinux [ pkg-config ]; diff --git a/renovate.json b/renovate.json index f928fe58..8a37ad37 100644 --- a/renovate.json +++ b/renovate.json @@ -10,7 +10,15 @@ { "matchManagers": ["gomod"], "matchDepNames": ["go"], - "rangeStrategy": "bump" + "matchDepTypes": ["golang"], + "enabled": false + }, + { + "matchManagers": ["gomod"], + "matchDepNames": ["go"], + "matchDepTypes": ["toolchain"], + "rangeStrategy": "bump", + "groupName": "go toolchain" }, { "matchManagers": ["github-actions"],