diff --git a/.github/workflows/update-lastupdated.yml b/.github/workflows/update-lastupdated.yml index 713b7977..7ed0f8ae 100644 --- a/.github/workflows/update-lastupdated.yml +++ b/.github/workflows/update-lastupdated.yml @@ -3,6 +3,11 @@ on: schedule: - cron: '0 0 * * 0' workflow_dispatch: + inputs: + backfill_limit: + description: Number of legacy plugin records to backfill in this run + required: false + default: '25' jobs: build: runs-on: ubuntu-latest @@ -15,9 +20,11 @@ jobs: - name: Update lastUpdated run: | gem install octokit + ruby -Itest test/plugin-recency-test.rb rake lastUpdated env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BACKFILL_LIMIT: ${{ github.event.inputs.backfill_limit || '25' }} - name: push run: | git config --global user.name 'Sketch Developer' diff --git a/Rakefile b/Rakefile index fd5cb340..a266d276 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,7 @@ require 'json' require 'time' require "./lib/plugin-directory-utils" +require "./lib/plugin-recency" GITHUB_AUTH_TOKEN = ENV['GITHUB_TOKEN'] @@ -145,45 +146,39 @@ end desc "Update `lastUpdated` field for all plugin in JSON" task :lastUpdated do - require 'octokit' - client = Octokit::Client.new(:access_token => GITHUB_AUTH_TOKEN) + abort 'GITHUB_TOKEN is required' if GITHUB_AUTH_TOKEN.to_s.empty? + client = Octokit::Client.new(:access_token => GITHUB_AUTH_TOKEN) json_data = get_plugins_from_json + metadata = PluginDirectory::GithubMetadata.new(GITHUB_AUTH_TOKEN).fetch(json_data) + updater = PluginDirectory::RecencyUpdater.new(github_client: client) + backfill_limit = ENV.fetch('BACKFILL_LIMIT', '25') + backfill_limit = backfill_limit == 'all' ? json_data.length : Integer(backfill_limit) + backfilled = 0 + updated = 0 json_data.each do |plugin| - # Only check for last push date for plugins with a repo - if plugin['owner'] && plugin['name'] - # puts "Updating #{titlefy(plugin['name'])}" - plugin_url = plugin['owner'] + "/" + plugin['name'] - begin - repo = client.repo(plugin_url) - # user = client.user(plugin['owner']) - # puts "— Plugin was updated at #{repo.pushed_at}" - plugin['lastUpdated'] = repo.pushed_at - rescue Exception => e - puts e - puts "https://github.com/#{plugin['owner']}/#{plugin['name']}" - end + next unless plugin['owner'] && plugin['name'] - # if plugin['name'] == plugin['title'] && plugin['title'] == nil - # puts "— Plugin title is wrong, fixing" - # plugin['title'] = titlefy(plugin['name']) - # end - else - if plugin['lastUpdated'].nil? - puts plugin['name'] - puts plugin['title'] - puts "— Plugin is not on GitHub, you may want to manually add a date" - end + repository = "#{plugin['owner']}/#{plugin['name']}" + repository_metadata = metadata[repository] + next unless repository_metadata + + if plugin['lastPushedAt'].nil? + next if backfilled >= backfill_limit + + backfilled += 1 end - puts + + updated += 1 if updater.update(plugin, repository_metadata) end + puts "Updated recency for #{updated} plugin(s); inspected #{backfilled} legacy record(s)" + File.open("plugins.json","w") do |f| f.write(JSON.pretty_generate(json_data, :indent => " ")) end - end desc "List authors" diff --git a/lib/plugin-recency.rb b/lib/plugin-recency.rb new file mode 100644 index 00000000..8ce08e86 --- /dev/null +++ b/lib/plugin-recency.rb @@ -0,0 +1,273 @@ +# encoding: utf-8 + +require 'json' +require 'net/http' +require 'rexml/document' +require 'time' +require 'uri' + +module PluginDirectory + class GithubMetadata + BATCH_SIZE = 50 + + def initialize(token) + @token = token + end + + def fetch(plugins) + repositories = plugins.each_with_object([]) do |plugin, result| + owner = plugin['owner'] + name = plugin['name'] + next unless valid_repository_part?(owner) && valid_repository_part?(name) + + result << [owner, name] + end.uniq + + repositories.each_slice(BATCH_SIZE).each_with_object({}) do |batch, metadata| + query_fields = batch.each_with_index.map do |(owner, name), index| + <<~GRAPHQL + repo#{index}: repository(owner: #{owner.to_json}, name: #{name.to_json}) { + pushedAt + latestRelease { + isDraft + isPrerelease + publishedAt + url + releaseAssets(first: 20) { + nodes { name downloadUrl } + } + } + } + GRAPHQL + end.join("\n") + + response = post_graphql("query {\n#{query_fields}\nrateLimit { cost remaining resetAt }\n}") + data = response.fetch('data', {}) + + batch.each_with_index do |(owner, name), index| + repository = data["repo#{index}"] + metadata["#{owner}/#{name}"] = repository if repository + end + + if response['errors'] + warn "GitHub GraphQL returned #{response['errors'].length} error(s) while reading plugin metadata" + end + end + end + + private + + def valid_repository_part?(value) + value.is_a?(String) && value.match?(/\A[A-Za-z0-9_.-]+\z/) + end + + def post_graphql(query) + uri = URI('https://api.github.com/graphql') + request = Net::HTTP::Post.new(uri) + request['Authorization'] = "Bearer #{@token}" + request['Accept'] = 'application/vnd.github+json' + request['User-Agent'] = 'sketch-plugin-directory' + request['X-GitHub-Api-Version'] = '2022-11-28' + request.body = JSON.generate(query: query) + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + raise "GitHub GraphQL request failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + end + end + + class RecencyUpdater + MAX_COMMIT_SCAN = 10 + ROOT_DOCUMENTATION = /\A(?:readme|license|contributing|code_of_conduct)(?:\..*)?\z/i + DOCUMENTATION_DIRECTORIES = %r{\A(?:\.github|docs)/}i + DISTRIBUTABLE_ASSET = /(?:\.sketchplugin|\.zip)\z/i + + def initialize(github_client:, appcast_loader: nil) + @github_client = github_client + @appcast_loader = appcast_loader || method(:load_url) + end + + def update(plugin, metadata) + pushed_at = parse_time(metadata['pushedAt']) + return false unless pushed_at + + formatted_push = format_time(pushed_at) + return false if plugin['lastPushedAt'] == formatted_push + + repository = "#{plugin['owner']}/#{plugin['name']}" + candidates = [] + release = release_candidate(metadata['latestRelease']) + candidates << release if release + + appcast = appcast_candidate(plugin['appcast'], repository) + candidates << appcast[:candidate] if appcast[:candidate] + + code = code_candidate(repository, appcast[:path], appcast[:has_items]) + candidates << code if code + + latest = candidates.compact.max_by { |candidate| candidate[:date] } + if latest + plugin['lastUpdated'] = format_time(latest[:date]) + plugin['lastUpdatedSource'] = latest[:source] + elsif plugin['lastUpdated'] && !plugin['lastUpdatedSource'] + plugin['lastUpdatedSource'] = 'legacy' + end + + plugin['lastPushedAt'] = formatted_push + true + rescue StandardError => error + warn "Could not update #{plugin['owner']}/#{plugin['name']}: #{error.message}" + false + end + + private + + def release_candidate(release) + return nil unless release + return nil if release['isDraft'] || release['isPrerelease'] + + assets = release.dig('releaseAssets', 'nodes') || [] + return nil unless assets.any? { |asset| asset['name'].to_s.match?(DISTRIBUTABLE_ASSET) } + + date = parse_time(release['publishedAt']) + date ? { date: date, source: 'release' } : nil + end + + def appcast_candidate(url, repository) + return { candidate: nil, has_items: nil, path: nil } if url.to_s.empty? + + feed = parse_appcast(@appcast_loader.call(url)) + location = github_appcast_location(url) + candidate = nil + + if feed[:has_items] + date = feed[:dates].max + if !date && location && location[:repository].casecmp(repository).zero? + commit = @github_client.commits(repository, path: location[:path], per_page: 1).first + date = commit_date(commit) if commit + end + candidate = { date: date, source: 'appcast' } if date + end + + { candidate: candidate, has_items: feed[:has_items], path: location && location[:path] } + end + + def code_candidate(repository, appcast_path, appcast_has_items) + commits = @github_client.commits(repository, per_page: MAX_COMMIT_SCAN) + commits.each do |commit| + detail = @github_client.commit(repository, commit_sha(commit)) + files = Array(value(detail, :files)).map { |file| value(file, :filename).to_s } + next unless meaningful_files?(files, appcast_path, appcast_has_items) + + date = commit_date(commit) + return { date: date, source: 'code' } if date + end + nil + end + + def meaningful_files?(files, appcast_path, appcast_has_items) + relevant = files.reject { |path| documentation_path?(path) } + return false if relevant.empty? + + if appcast_has_items == false && appcast_path + non_appcast = relevant.reject { |path| path.casecmp(appcast_path).zero? } + return false if non_appcast.empty? + end + + true + end + + def documentation_path?(path) + path.match?(DOCUMENTATION_DIRECTORIES) || (!path.include?('/') && path.match?(ROOT_DOCUMENTATION)) + end + + def parse_appcast(body) + stripped = body.to_s.lstrip + return parse_json_appcast(stripped) if stripped.start_with?('{', '[') + + document = REXML::Document.new(stripped) + items = REXML::XPath.match(document, '//item') + dates = items.flat_map do |item| + %w[pubDate date updated published published_at releaseDate].map do |field| + element = item.elements[field] + parse_time(element.text) if element + end + end.compact + { has_items: !items.empty?, dates: dates } + end + + def parse_json_appcast(body) + data = JSON.parse(body) + entries = if data.is_a?(Array) + data + else + data['items'] || data['releases'] || data['versions'] || (data['version'] ? [data] : []) + end + dates = entries.flat_map do |entry| + next [] unless entry.is_a?(Hash) + + %w[pubDate date updated published published_at releaseDate].map { |field| parse_time(entry[field]) } + end.compact + { has_items: !entries.empty?, dates: dates } + end + + def github_appcast_location(url) + uri = URI(url) + parts = uri.path.split('/').reject(&:empty?) + if uri.host == 'raw.githubusercontent.com' && parts.length >= 4 + path_start = parts[2] == 'refs' && parts[3] == 'heads' ? 5 : 3 + return { repository: "#{parts[0]}/#{parts[1]}", path: parts.drop(path_start).join('/') } + end + if uri.host == 'github.com' && parts[2] == 'raw' && parts.length >= 5 + return { repository: "#{parts[0]}/#{parts[1]}", path: parts.drop(4).join('/') } + end + + nil + rescue URI::InvalidURIError + nil + end + + def load_url(url, redirects = 3) + raise 'Too many redirects while loading appcast' if redirects < 0 + + uri = URI(url) + response = Net::HTTP.get_response(uri) + return response.body if response.is_a?(Net::HTTPSuccess) + return load_url(URI.join(url, response['location']).to_s, redirects - 1) if response.is_a?(Net::HTTPRedirection) + + raise "Appcast request failed with HTTP #{response.code}" + end + + def value(object, key) + return object[key] || object[key.to_s] if object.is_a?(Hash) + + object.public_send(key) if object.respond_to?(key) + end + + def commit_sha(commit) + value(commit, :sha) + end + + def commit_date(commit) + commit_data = value(commit, :commit) + committer = value(commit_data, :committer) + parse_time(value(committer, :date)) + end + + def parse_time(value) + return value.utc if value.is_a?(Time) + return nil if value.to_s.empty? + + Time.parse(value.to_s).utc + rescue ArgumentError + nil + end + + def format_time(value) + value.utc.strftime('%Y-%m-%d %H:%M:%S UTC') + end + end +end diff --git a/test/plugin-recency-test.rb b/test/plugin-recency-test.rb new file mode 100644 index 00000000..f66de1c8 --- /dev/null +++ b/test/plugin-recency-test.rb @@ -0,0 +1,140 @@ +# encoding: utf-8 + +require 'minitest/autorun' +require_relative '../lib/plugin-recency' + +class FakeGithubClient + attr_reader :calls + + def initialize(commits: [], details: {}, appcast_commits: [], error: nil) + @commits = commits + @details = details + @appcast_commits = appcast_commits + @error = error + @calls = [] + end + + def commits(repository, options = {}) + @calls << [:commits, repository, options] + raise @error if @error + + options[:path] ? @appcast_commits : @commits + end + + def commit(repository, sha) + @calls << [:commit, repository, sha] + @details.fetch(sha) + end +end + +class PluginRecencyTest < Minitest::Test + def commit(sha, date) + { sha: sha, commit: { committer: { date: date } } } + end + + def metadata(pushed_at, release = nil) + { 'pushedAt' => pushed_at, 'latestRelease' => release } + end + + def plugin(overrides = {}) + { + 'owner' => 'example', + 'name' => 'plugin', + 'lastUpdated' => '2020-01-01 00:00:00 UTC' + }.merge(overrides) + end + + def test_uses_newest_meaningful_signal + source_commit = commit('source', '2024-03-01T00:00:00Z') + client = FakeGithubClient.new( + commits: [source_commit], + details: { 'source' => { files: [{ filename: 'README.md' }, { filename: 'src/plugin.js' }] } } + ) + release = { + 'publishedAt' => '2024-01-01T00:00:00Z', + 'isDraft' => false, + 'isPrerelease' => false, + 'releaseAssets' => { 'nodes' => [{ 'name' => 'plugin.sketchplugin.zip' }] } + } + appcast = '1 Feb 2024 00:00:00 UTC' + record = plugin('appcast' => 'https://example.com/appcast.xml') + + updater = PluginDirectory::RecencyUpdater.new(github_client: client, appcast_loader: ->(_url) { appcast }) + updater.update(record, metadata('2024-03-01T00:00:00Z', release)) + + assert_equal '2024-03-01 00:00:00 UTC', record['lastUpdated'] + assert_equal 'code', record['lastUpdatedSource'] + end + + def test_readme_warning_does_not_make_turkish_data_recent + appcast_commit = commit('appcast', '2021-05-08T08:18:19Z') + warning_commit = commit('warning', '2026-07-28T11:21:08Z') + code_commit = commit('code', '2021-05-08T08:18:18Z') + client = FakeGithubClient.new( + commits: [warning_commit, code_commit], + appcast_commits: [appcast_commit], + details: { + 'warning' => { files: [{ filename: 'README.md' }] }, + 'code' => { files: [{ filename: 'src/plugin.js' }] } + } + ) + feed = '' + record = plugin( + 'owner' => 'ozgurgunes', + 'name' => 'Sketch-Turkish-Data', + 'appcast' => 'https://raw.githubusercontent.com/ozgurgunes/Sketch-Turkish-Data/master/.appcast.xml' + ) + + updater = PluginDirectory::RecencyUpdater.new(github_client: client, appcast_loader: ->(_url) { feed }) + updater.update(record, metadata('2026-07-28T11:21:08Z')) + + assert_equal '2021-05-08 08:18:19 UTC', record['lastUpdated'] + assert_equal 'appcast', record['lastUpdatedSource'] + end + + def test_empty_deprecation_appcast_preserves_previous_activity + deprecation = commit('deprecation', '2026-07-15T07:48:48Z') + client = FakeGithubClient.new( + commits: [deprecation], + details: { + 'deprecation' => { files: [{ filename: '.appcast.xml' }, { filename: 'README.md' }] } + } + ) + record = plugin( + 'appcast' => 'https://raw.githubusercontent.com/example/plugin/master/.appcast.xml', + 'lastUpdated' => '2025-07-22 12:54:03 UTC', + 'lastUpdatedSource' => 'appcast' + ) + empty_feed = 'Deprecated' + + updater = PluginDirectory::RecencyUpdater.new(github_client: client, appcast_loader: ->(_url) { empty_feed }) + updater.update(record, metadata('2026-07-15T07:48:48Z')) + + assert_equal '2025-07-22 12:54:03 UTC', record['lastUpdated'] + assert_equal 'appcast', record['lastUpdatedSource'] + assert_equal '2026-07-15 07:48:48 UTC', record['lastPushedAt'] + end + + def test_unchanged_push_skips_deep_inspection + client = FakeGithubClient.new(error: 'should not be called') + record = plugin('lastPushedAt' => '2026-01-01 00:00:00 UTC') + updater = PluginDirectory::RecencyUpdater.new(github_client: client) + + refute updater.update(record, metadata('2026-01-01T00:00:00Z')) + assert_empty client.calls + end + + def test_api_failure_preserves_previous_values_for_retry + client = FakeGithubClient.new(error: 'temporary failure') + record = plugin( + 'lastUpdated' => '2024-01-01 00:00:00 UTC', + 'lastUpdatedSource' => 'code', + 'lastPushedAt' => '2024-01-01 00:00:00 UTC' + ) + updater = PluginDirectory::RecencyUpdater.new(github_client: client) + + refute updater.update(record, metadata('2026-01-01T00:00:00Z')) + assert_equal '2024-01-01 00:00:00 UTC', record['lastUpdated'] + assert_equal '2024-01-01 00:00:00 UTC', record['lastPushedAt'] + end +end