diff --git a/app/assets/javascripts/admin/commons/conditional.js b/app/assets/javascripts/admin/commons/conditional.js
index 1faf046150..74956ac74e 100644
--- a/app/assets/javascripts/admin/commons/conditional.js
+++ b/app/assets/javascripts/admin/commons/conditional.js
@@ -20,22 +20,31 @@ window.osuny.conditional = {
update: function (source, value) {
'use strict';
var allTargets = document.querySelectorAll('[data-conditional-source="' + source + '"]'),
- activeTargets = document.querySelectorAll('[data-conditional-source="' + source + '"][data-conditional-value="' + value + '"]'),
i,
target;
for (i = 0; i < allTargets.length; i += 1) {
target = allTargets[i];
- this.hide(target);
+ if (this.matches(target, value)) {
+ this.show(target);
+ } else {
+ this.hide(target);
+ }
}
- for (i = 0; i < activeTargets.length; i += 1) {
- target = activeTargets[i];
- this.show(target);
+ },
+
+ // data-conditional-value="x": displayed only when value equals to x
+ // data-conditional-value-not="x": displayed when value different from x
+ matches: function (target, value) {
+ 'use strict';
+ if (target.hasAttribute('data-conditional-value-not')) {
+ return target.getAttribute('data-conditional-value-not') !== value;
}
+ return target.getAttribute('data-conditional-value') === value;
},
hide: function (target) {
'use strict';
- var input = target.querySelector('select');
+ var input = target.querySelector('select, input, textarea');
target.classList.add('d-none');
if (input) {
input.removeAttribute('required');
@@ -45,11 +54,14 @@ window.osuny.conditional = {
show: function (target) {
'use strict';
- var input = target.querySelector('select');
+ var input = target.querySelector('select, input, textarea'),
+ optional = target.getAttribute('data-conditional-optional') === 'true';
target.classList.remove('d-none');
if (input) {
input.removeAttribute('disabled');
- input.setAttribute('required', 'required');
+ if (!optional) {
+ input.setAttribute('required', 'required');
+ }
}
},
diff --git a/app/assets/javascripts/admin/commons/dynamic_placeholder.js b/app/assets/javascripts/admin/commons/dynamic_placeholder.js
new file mode 100644
index 0000000000..60f9fb98ad
--- /dev/null
+++ b/app/assets/javascripts/admin/commons/dynamic_placeholder.js
@@ -0,0 +1,51 @@
+/* global */
+window.osuny.dynamicPlaceholder = {
+ init: function () {
+ 'use strict';
+ var element,
+ i;
+ this.elements = document.querySelectorAll('[data-placeholder-source]');
+ for (i = 0; i < this.elements.length; i += 1) {
+ element = this.elements[i];
+ this.bind(element);
+ }
+ },
+
+ bind: function (element) {
+ 'use strict';
+ var source = document.getElementById(element.getAttribute('data-placeholder-source'));
+ if (!source) {
+ return;
+ }
+ source.addEventListener('change', this.update.bind(this, element, source));
+ this.update(element, source);
+ },
+
+ // data-placeholder-map='{"value-from-source": "placeholder to display", ...}'
+ update: function (element, source) {
+ 'use strict';
+ var map = JSON.parse(element.getAttribute('data-placeholder-map') || '{}'),
+ value = map[this.getValue(source)];
+ if (value) {
+ element.setAttribute('placeholder', value);
+ } else {
+ element.removeAttribute('placeholder');
+ }
+ },
+
+ getValue: function (element) {
+ 'use strict';
+ if (element.type === 'checkbox') {
+ return element.checked;
+ } else {
+ return element.value;
+ }
+ },
+
+ invoke: function () {
+ 'use strict';
+ return {
+ init: this.init.bind(this)
+ };
+ }
+}.invoke().init();
diff --git a/app/assets/javascripts/admin/commons/hide_on_change.js b/app/assets/javascripts/admin/commons/hide_on_change.js
new file mode 100644
index 0000000000..c063267883
--- /dev/null
+++ b/app/assets/javascripts/admin/commons/hide_on_change.js
@@ -0,0 +1,36 @@
+/* global */
+window.osuny.hideOnChange = {
+ init: function () {
+ 'use strict';
+ var element,
+ i;
+ this.elements = document.querySelectorAll('[data-hide-on-change]');
+ for (i = 0; i < this.elements.length; i += 1) {
+ element = this.elements[i];
+ this.bind(element);
+ }
+ },
+
+ bind: function (element) {
+ 'use strict';
+ var container = document.getElementById(element.getAttribute('data-hide-on-change'));
+ if (!container) {
+ return;
+ }
+ container.addEventListener('input', this.hide.bind(this, element));
+ container.addEventListener('change', this.hide.bind(this, element));
+ },
+
+ // If any field in the container has changed, hide the element
+ hide: function (element) {
+ 'use strict';
+ element.classList.add('d-none');
+ },
+
+ invoke: function () {
+ 'use strict';
+ return {
+ init: this.init.bind(this)
+ };
+ }
+}.invoke().init();
diff --git a/app/controllers/admin/communication/websites/settings_controller.rb b/app/controllers/admin/communication/websites/settings_controller.rb
index 31d99e0bfb..4a100eca5f 100644
--- a/app/controllers/admin/communication/websites/settings_controller.rb
+++ b/app/controllers/admin/communication/websites/settings_controller.rb
@@ -35,10 +35,19 @@ def redirects
def technical
breadcrumb
add_breadcrumb t('admin.communication.website.technical.label')
+ check_git_access if params[:check_git_access].present?
end
protected
+ def check_git_access
+ if @website.check_git_access
+ flash.now[:notice] = t('admin.communication.website.technical.check_git_access.success')
+ else
+ flash.now[:alert] = @website.errors.full_messages.to_sentence
+ end
+ end
+
def breadcrumb
super
add_breadcrumb t('admin.subnav.settings'), edit_admin_communication_website_path(@website, website_id: nil)
diff --git a/app/controllers/admin/communication/websites_controller.rb b/app/controllers/admin/communication/websites_controller.rb
index be6f930e68..57f32452d6 100644
--- a/app/controllers/admin/communication/websites_controller.rb
+++ b/app/controllers/admin/communication/websites_controller.rb
@@ -65,6 +65,7 @@ def show
can?(:read, Communication::Website::Jobboard::Job)
# Git files
@desynchronized_generated_git_files = @website.desynchronized_generated_git_files
+ @sync_with_git_scheduled = @website.sync_with_git_scheduled?
breadcrumb
end
@@ -103,8 +104,17 @@ def update
else
load_invalid_localization
breadcrumb
- add_breadcrumb t('edit')
- render :edit, status: :unprocessable_content
+ case params[:_return_to]
+ when 'technical'
+ @l10n = @website.localization_for(current_language)
+ @feature_nav = 'navigation/admin/communication/website/settings'
+ add_breadcrumb t('admin.subnav.settings'), edit_admin_communication_website_path(@website, website_id: nil)
+ add_breadcrumb t('admin.communication.website.technical.label')
+ render 'admin/communication/websites/settings/technical', status: :unprocessable_content
+ else
+ add_breadcrumb t('edit')
+ render :edit, status: :unprocessable_content
+ end
end
end
diff --git a/app/controllers/server/websites_controller.rb b/app/controllers/server/websites_controller.rb
index 5c5e228cd0..204bf16ced 100644
--- a/app/controllers/server/websites_controller.rb
+++ b/app/controllers/server/websites_controller.rb
@@ -45,6 +45,12 @@ def analyse
notice: t('admin.communication.website.git_file.analysis.launched')
end
+ def force_resync_with_git
+ @website.force_resync_with_git!
+ redirect_back fallback_location: server_website_path(@website),
+ notice: t('server_admin.websites.force_resync_with_git_notice')
+ end
+
def edit
breadcrumb
add_breadcrumb t('edit')
diff --git a/app/jobs/communication/website/base_job.rb b/app/jobs/communication/website/base_job.rb
index 2ba3e15412..3d37222711 100644
--- a/app/jobs/communication/website/base_job.rb
+++ b/app/jobs/communication/website/base_job.rb
@@ -5,8 +5,38 @@ class Communication::Website::BaseJob < ApplicationJob
queue_as :elephants
- discard_on ActiveJob::DeserializationError, # Discard if object does not exist anymore
- Octokit::InvalidRepository # Discard if repository is invalid to prevent useless API calls
+ # Discard if object does not exist anymore
+ discard_on ActiveJob::DeserializationError
+
+ # For any error not handled by our custom error classes in discard_on,
+ # requests that return error are retried 5 times with exponential backoff
+ # if it does not self-heal, notify admins with raw api error message.
+ retry_on Git::Providers::Abstract::Error, wait: :polynomially_longer, attempts: 5 do |job, error|
+ job.notify_git_access_broken(error)
+ end
+
+ # A client side error would not self-heal, notify admins.
+ discard_on Git::Providers::Abstract::ClientError do |job, error|
+ job.notify_git_access_broken(error)
+ end
+
+ # Discard immediately and notify admins
+ # (a wrong or expired token does not come back on its own).
+ discard_on Git::Providers::Abstract::Unauthorized do |job, error|
+ job.notify_invalid_access_token
+ end
+
+ # Discard if the remote git access has drifted since it was last validated
+ # (repository/branch deleted, branch newly protected, endpoint reconfigured...).
+ # Unlike a generic network error, nothing self-heals this on the next run: notify admins.
+ discard_on Git::Providers::Abstract::RepositoryForbidden,
+ Git::Providers::Abstract::RepositoryNotFound,
+ Git::Providers::Abstract::BranchNotFound,
+ Git::Providers::Abstract::BranchProtected,
+ Git::Providers::Abstract::InvalidEndpoint,
+ Git::Providers::Abstract::WorkflowsForbidden do |job, error|
+ job.notify_git_access_broken(error)
+ end
# Retry the job after 1 minute if it is interrupted, to prevent queue from being blocked
retry_on GoodJob::InterruptError, wait: 1.minute, attempts: Float::INFINITY
@@ -33,6 +63,14 @@ def perform(website_id, options = {})
end
end
+ def notify_git_access_broken(error)
+ website&.notify_git_access_broken!(error)
+ end
+
+ def notify_invalid_access_token
+ website&.notify_invalid_access_token!
+ end
+
protected
def execute
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 81c5b4b8de..5326ca2a72 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -30,10 +30,32 @@ def emergency_message(emergency_message, user, lang)
end
end
+ def website_git_access_broken(website, user, error_message)
+ @website = website
+ @error_message = error_message
+ merge_with_university_infos(@website.university, {})
+ # Link with check_git_access: true to trigger automatically the check
+ @url = technical_admin_communication_website_url(
+ id: @website.id, check_git_access: true,
+ lang: @website.default_language.iso_code
+ )
+ I18n.with_locale(user.language.iso_code) do
+ subject = t('mailers.notifications.website_git_access_broken.subject', website: website)
+ mail(
+ from: user.university.mail_from[:full],
+ to: user.email,
+ subject: subject
+ ) if should_send?(user.email)
+ end
+ end
+
def website_invalid_access_token(website, user)
@website = website
merge_with_university_infos(@website.university, {})
- @url = edit_admin_communication_website_url(@website, lang: @website.default_language.iso_code)
+ @url = technical_admin_communication_website_url(
+ id: @website.id,
+ lang: @website.default_language.iso_code
+ )
I18n.with_locale(user.language.iso_code) do
subject = t('mailers.notifications.website_invalid_access_token.subject', website: website)
mail(
diff --git a/app/models/communication/website.rb b/app/models/communication/website.rb
index d42a9a4681..42a67a20fa 100644
--- a/app/models/communication/website.rb
+++ b/app/models/communication/website.rb
@@ -105,7 +105,8 @@ class Communication::Website < ApplicationRecord
enum :git_provider, {
github: 0,
- gitlab: 1
+ gitlab: 1,
+ forgejo: 2
}
belongs_to :default_language, class_name: "Language"
diff --git a/app/models/communication/website/with_git_repository.rb b/app/models/communication/website/with_git_repository.rb
index cd3197ba0c..808b4c610c 100644
--- a/app/models/communication/website/with_git_repository.rb
+++ b/app/models/communication/website/with_git_repository.rb
@@ -19,18 +19,51 @@ module Communication::Website::WithGitRepository
dependent: :destroy
alias_method :git_file_layouts, :website_git_file_layouts
+ # Local check to prevent network request to API if repositry format is invalid
+ validate :repository_format_is_valid, if: -> { repository.present? }
+
+ validate :access_token_allows_repository_access, if: :should_validate_git_access?
+
after_save :mark_obsolete_git_files, if: :should_clean_on_git?
+ # Used to automatically mark all git_files as desynchronized if git_provider or repository have changed
+ # In that case all content is not in the new repository and should be pushed
+ after_save :force_resync_with_git!, if: :should_force_resync_with_git?
+
scope :with_repository, -> { where.not(repository: [nil, '']) }
scope :with_desynchronized_git_files, -> {
joins(:website_git_files).merge(Communication::Website::GitFile.desynchronized).distinct
}
end
+ class_methods do
+ def git_provider_default_endpoints
+ git_providers.keys.index_with do |provider|
+ provider_class = "Git::Providers::#{provider.titleize}".constantize
+ provider_class::DEFAULT_ENDPOINT if provider_class.const_defined?(:DEFAULT_ENDPOINT, false)
+ end
+ end
+
+ def git_provider_default_branches
+ git_providers.keys.index_with do |provider|
+ provider_class = "Git::Providers::#{provider.titleize}".constantize
+ provider_class::DEFAULT_BRANCH
+ end
+ end
+ end
+
def git_repository
@git_repository ||= Git::Repository.new self
end
+ def git_provider_default_endpoint
+ self.class.git_provider_default_endpoints[git_provider]
+ end
+
+ def git_provider_default_branch
+ self.class.git_provider_default_branches[git_provider]
+ end
+
def repository_url
git_repository.url
end
@@ -44,23 +77,53 @@ def identify_git_files_safely
end
def sync_with_git
- update_column(:last_sync_at, Time.now)
Communication::Website::SyncWithGitJob.perform_later(id)
end
def sync_with_git_safely
- return unless git_repository.valid?
- git_repository.git_files = git_files.generated
- .desynchronized_until(last_sync_at)
+ sync_cutoff = Time.zone.now
+ desynchronized_git_files = git_files.generated
+ .desynchronized_until(sync_cutoff)
.order(:desynchronized_at)
.limit(git_repository.batch_size)
- git_repository.sync!
- if git_files.desynchronized_until(last_sync_at).any?
+ return if desynchronized_git_files.empty?
+ return unless git_repository.valid?
+ git_repository.git_files = desynchronized_git_files
+ begin
+ git_repository.sync!
+ rescue Git::Providers::Abstract::Error => e
+ # We re-check all git access parameters only if a failure occurs
+ # This triggers the notification email if needed
+ git_repository.check_repository_access!
+ # If all params are OK, it is a temporary network error
+ raise e
+ end
+ # We updates last_sync_at when sync is really done/successful
+ update_column(:last_sync_at, sync_cutoff)
+ if git_files.desynchronized_until(sync_cutoff).any?
# More than one batch, we need to requeue the job
Communication::Website::SyncWithGitJob.perform_later(id)
end
end
+ # If git_provider or repository fields have changed, mark all git_files as desynchronized
+ # User must still trigger the sync manually using the Synchronize button
+ def force_resync_with_git!
+ git_files.generated.update_all(desynchronized: true, desynchronized_at: Time.zone.now)
+ end
+
+ def should_force_resync_with_git?
+ return false unless repository.present?
+ git_access_field_saved_change?(:repository) || git_access_field_saved_change?(:git_provider)
+ end
+
+ # Check if specific fields have been modified after save of website record
+ def git_access_field_saved_change?(attribute)
+ return false unless public_send("saved_change_to_#{attribute}?")
+ before, after = public_send("saved_change_to_#{attribute}")
+ before.presence != after.presence
+ end
+
def generate_git_file_for_array(array)
array.each do |object|
generate_git_file_for_object(object)
@@ -86,16 +149,138 @@ def mark_obsolete_git_files
end
end
+ def should_validate_git_access?
+ return false if access_token.blank?
+ git_access_field_changed?(:access_token) ||
+ git_access_field_changed?(:git_provider) ||
+ git_access_field_changed?(:git_endpoint) ||
+ git_access_field_changed?(:repository) ||
+ git_access_field_changed?(:git_branch)
+ end
+
+ def git_access_field_changed?(attribute)
+ return false unless public_send("will_save_change_to_#{attribute}?")
+ before, after = public_send("#{attribute}_change")
+ before.presence != after.presence
+ end
+
+
+ def repository_format_is_valid
+ git_repository.check_repository_format!
+ rescue Git::Providers::Abstract::InvalidRepositoryIdentifier
+ errors.add(:repository, :invalid)
+ end
+
+ def access_token_allows_repository_access
+ git_repository.check_repository_access!
+ rescue StandardError => e
+ add_git_access_error(e)
+ end
+
+ # Triggered via url parameter ?check_git_access=true
+ def check_git_access
+ if access_token.blank?
+ errors.add(:access_token, :blank)
+ return false
+ end
+ git_repository.check_repository_access!
+ true
+ rescue StandardError => e
+ add_git_access_error(e)
+ false
+ end
+
+ GIT_ACCESS_FIELDS = %i[git_provider git_endpoint access_token repository git_branch].freeze
+
+ # Used to hide the "Test connection" button in case of error
+ # to force the user to use the Save button instead
+ def git_access_errors?
+ GIT_ACCESS_FIELDS.any? { |field| errors[field].present? }
+ end
+
+ # Mapping between each error/exception class and the corresponding field and message
+ def add_git_access_error(exception)
+ case exception
+ when Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, Timeout::Error, EOFError,
+ Git::Providers::Abstract::EndpointUnreachable
+ errors.add(:git_endpoint, :unreachable)
+ when Git::Providers::Abstract::InvalidEndpoint
+ errors.add(:git_endpoint, :invalid)
+ when Git::Providers::Abstract::Unauthorized
+ errors.add(:access_token, :unauthorized)
+ when Git::Providers::Abstract::RepositoryForbidden
+ errors.add(:access_token, :forbidden)
+ when Git::Providers::Abstract::RepositoryNotFound
+ errors.add(:repository, :not_found)
+ when Git::Providers::Abstract::InvalidRepositoryIdentifier, Octokit::InvalidRepository
+ errors.add(:repository, :invalid)
+ when Git::Providers::Abstract::BranchNotFound
+ errors.add(:git_branch, :not_found)
+ when Git::Providers::Abstract::BranchProtected
+ errors.add(:git_branch, :protected)
+ when Git::Providers::Abstract::WorkflowsForbidden
+ errors.add(:access_token, :workflows_forbidden)
+ else
+ raise exception
+ end
+ end
+
+ GIT_ACCESS_ERROR_I18N_SCOPE = 'mailers.notifications.website_git_access_broken.errors'
+
+ # Translation strings to be used in the notification email website_git_access_broken
+ def git_access_error_message(exception)
+ case exception
+ when Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, Timeout::Error, EOFError,
+ Git::Providers::Abstract::EndpointUnreachable
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.unreachable", endpoint: git_endpoint.presence || git_provider_default_endpoint)
+ when Git::Providers::Abstract::InvalidEndpoint
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.invalid_endpoint")
+ when Git::Providers::Abstract::Unauthorized
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.unauthorized")
+ when Git::Providers::Abstract::RepositoryForbidden
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.repository_forbidden", repository: repository)
+ when Git::Providers::Abstract::RepositoryNotFound
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.repository_not_found", repository: repository)
+ when Git::Providers::Abstract::InvalidRepositoryIdentifier, Octokit::InvalidRepository
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.invalid_repository", repository: repository)
+ when Git::Providers::Abstract::BranchNotFound
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.branch_not_found", branch: git_branch.presence || git_provider_default_branch)
+ when Git::Providers::Abstract::BranchProtected
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.branch_protected", branch: git_branch.presence || git_provider_default_branch)
+ when Git::Providers::Abstract::WorkflowsForbidden
+ I18n.t("#{GIT_ACCESS_ERROR_I18N_SCOPE}.workflows_forbidden")
+ else
+ exception.message
+ end
+ end
+
+ # Shared between notify_invalid_access_token! and notify_git_access_broken!
+ def git_access_users_to_notify
+ university.users.where(role: [:server_admin, :admin]) + university.users.website_manager.where(id: manager_ids)
+ end
+
def invalidate_access_token!
# Nullify the expired token
update_column :access_token, nil
- # Notify admins and website managers managing this website.
- users_to_notify = university.users.admin + university.users.website_manager.where(id: manager_ids)
- users_to_notify.each do |user|
+ end
+
+ # Notify admins that the token was nullified; kept separate from
+ # invalidate_access_token! because it should only fire from a job context
+ def notify_invalid_access_token!
+ git_access_users_to_notify.each do |user|
NotificationMailer.website_invalid_access_token(self, user).deliver_later
end
end
+ # Send notification to admins when token is still valid but sync is not possible anymore
+ # (wrong perms, repo deleted/moved, branch became protected, etc.)
+ def notify_git_access_broken!(error)
+ git_access_users_to_notify.each do |user|
+ error_message = I18n.with_locale(user.language.iso_code) { git_access_error_message(error) }
+ NotificationMailer.website_git_access_broken(self, user, error_message).deliver_later
+ end
+ end
+
def should_clean_on_git?
# Clean website if about was present and changed
saved_change_to_about_id? && about_id_before_last_save.present?
@@ -107,11 +292,19 @@ def update_theme_version
def update_theme_version_safely
return unless git_repository.valid?
- git_repository.update_theme_version!
+ begin
+ git_repository.update_theme_version!
+ rescue Git::Providers::Abstract::Error => e
+ # We re-check all git access parameters only if a failure occurs
+ git_repository.check_repository_access!
+ # If all params are OK, it is a temporary network error
+ raise e
+ end
end
def analyse_repository_safely
return unless git_repository.valid?
+ git_repository.check_repository_access!
Git::OrphanAndLayoutAnalyzer.new(self).launch
end
@@ -125,4 +318,13 @@ def desynchronized_generated_git_files
last_sync_at.nil? ? git_files_list.desynchronized
: git_files_list.desynchronized_since(last_sync_at)
end
+
+ # Checks if a sync is already scheduled for this website
+ # (We hide the Synchronize button in that case to prevent duplicates jobs enqueing)
+ def sync_with_git_scheduled?
+ GoodJob::Job.job_class('Communication::Website::SyncWithGitJob')
+ .unfinished
+ .where("serialized_params -> 'arguments' ->> 0 = ?", id)
+ .exists?
+ end
end
diff --git a/app/services/git/providers/abstract.rb b/app/services/git/providers/abstract.rb
index 294b336cf5..e8c68483df 100644
--- a/app/services/git/providers/abstract.rb
+++ b/app/services/git/providers/abstract.rb
@@ -1,5 +1,30 @@
class Git::Providers::Abstract
- attr_reader :git_repository, :endpoint, :branch, :access_token, :repository
+ # Marker modules for errors with the same meaning across providers (business logic errors).
+ # Each provider includes the matching module in its own error class.
+ # Callers can handle every provider with a single check.
+ module Error; end
+ module ClientError
+ include Error
+ end
+ module InvalidEndpoint; end
+ module EndpointUnreachable; end
+ module Unauthorized; end
+ module RepositoryNotFound; end
+ module RepositoryForbidden; end
+ module BranchNotFound; end
+ module BranchProtected; end
+ # GitHub specific, the repository and branch is writable but not under .github/workflow
+ # needed by osuny to write Deuxfleurs deployment workflow in the repository
+ module WorkflowsForbidden; end
+
+ # Concrete class if format of repository identifier is invalid (local check only)
+ class InvalidRepositoryIdentifier < StandardError; end
+
+ # Default repository identifier format (organization/repository, one level only)
+ REPOSITORY_FORMAT = %r{\A[^/\s]+/[^/\s]+\z}.freeze
+ DEFAULT_BRANCH = 'main'.freeze
+
+ attr_reader :git_repository, :access_token, :repository
def initialize(git_repository)
@git_repository = git_repository
@@ -9,6 +34,44 @@ def initialize(git_repository)
@repository = git_repository.website.repository
end
+ def endpoint
+ @endpoint.presence || self.class::DEFAULT_ENDPOINT
+ end
+
+ def branch
+ @branch.presence || self.class::DEFAULT_BRANCH
+ end
+
+ def check_repository_access!
+ check_repository_format!
+ check_endpoint!
+ return unless access_token.present?
+ check_repository_push_access!
+ check_branch_push_access!
+ rescue Git::Providers::Abstract::Unauthorized => e
+ git_repository.website.invalidate_access_token!
+ raise e
+ end
+
+ # Local check only without querying the provider API
+ def check_repository_format!
+ return if repository.to_s.match?(self.class::REPOSITORY_FORMAT)
+ raise InvalidRepositoryIdentifier, "'#{repository}' is not a valid repository identifier for #{self.class.name.demodulize}"
+ end
+
+ def check_endpoint!
+ # Some provider may not need an endpoint (GitHub)
+ end
+
+ def check_repository_push_access!
+ raise NoMethodError, "You must implement `check_repository_push_access!` in #{self.class.name}"
+ end
+
+ def check_branch_push_access!
+ raise NoMethodError, "You must implement `check_branch_push_access!` in #{self.class.name}"
+ end
+
+
def valid?
repository.present? && access_token.present?
end
@@ -37,10 +100,6 @@ def push(commit_message)
raise NoMethodError, "You must implement the `push` method in #{self.class.name}"
end
- def previous_sha(git_file)
- git_sha(git_file.previous_path)
- end
-
def computed_sha(string)
raise NoMethodError, "You must implement the `computed_sha` method in #{self.class.name}"
end
@@ -75,4 +134,20 @@ def theme_update_commit_message
theme_name = ENV["GITHUB_WEBSITE_THEME_REPOSITORY"].to_s.split("/").last
"Updated #{theme_name} version"
end
+
+ def tree_item_at_path(path)
+ return if path.nil?
+ tree_items_by_path[path]
+ end
+
+ def tree_items_by_path
+ @tree_items_by_path ||= tree.index_by(&:path)
+ end
+
+ # To be called after a successful push when the remote repository has changed
+ def reset_tree_cache!
+ @tree = nil
+ @tree_items_by_path = nil
+ @files_in_the_repository = nil
+ end
end
diff --git a/app/services/git/providers/concerns/rest_client.rb b/app/services/git/providers/concerns/rest_client.rb
new file mode 100644
index 0000000000..c6e632b970
--- /dev/null
+++ b/app/services/git/providers/concerns/rest_client.rb
@@ -0,0 +1,54 @@
+# Utility module to be used by providers accessible by a REST API and without a dedicated client ruby gem
+# Include this module in a concrete provider and implement 'request_headers'
+# See forgejo provider for an example of usage
+module Git::Providers::Concerns::RestClient
+ # GET request with JSON response. Raises HTTPError on any non-2xx status, NetworkError
+ # on a network-level failure, or InvalidResponseError if the response is not valid JSON.
+ def get(path, params = {})
+ response = get_raw(path, params)
+ JSON.parse(response.body)
+ rescue JSON::ParserError => e
+ raise Git::Providers::Concerns::RestClient::InvalidResponseError, "Invalid JSON response from #{path}: #{e.message}"
+ end
+
+ # Raw GET request. Returns the full Net::HTTPResponse object on success, raises
+ # HTTPError on any non-2xx status, or NetworkError on a network-level failure.
+ def get_raw(path, params = {})
+ uri = build_uri(path, params)
+ response = http(uri).get(uri.request_uri, request_headers)
+ raise Git::Providers::Concerns::RestClient::HTTPError.from_response(response) unless response.is_a?(Net::HTTPSuccess)
+ response
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, Timeout::Error, EOFError => e
+ raise Git::Providers::Concerns::RestClient::NetworkError, e.message
+ end
+
+ # Strict write request (POST, PUT, DELETE): raises HTTPError unless the response is a
+ # 2xx success, or NetworkError on a network-level failure.
+ def rest_request(method, path, payload)
+ uri = build_uri(path)
+ klass = { post: Net::HTTP::Post, put: Net::HTTP::Put, delete: Net::HTTP::Delete }.fetch(method)
+ req = klass.new(uri.request_uri, request_headers)
+ req.body = payload.to_json
+ response = http(uri).request(req)
+ raise Git::Providers::Concerns::RestClient::HTTPError.from_response(response) unless response.is_a?(Net::HTTPSuccess)
+ response
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, Timeout::Error, EOFError => e
+ raise Git::Providers::Concerns::RestClient::NetworkError, e.message
+ end
+
+ # Build the full URI to call (endpoint base + relative path)
+ # and optional query string parameters.
+ def build_uri(path, params = {})
+ base = endpoint.delete_suffix('/')
+ uri = URI.parse("#{base}#{path}")
+ uri.query = URI.encode_www_form(params) if params.present?
+ uri
+ end
+
+ # Creates the Net::HTTP client for a specified URI (with SSL if necessary).
+ def http(uri)
+ client = Net::HTTP.new(uri.host, uri.port)
+ client.use_ssl = uri.scheme == 'https'
+ client
+ end
+end
\ No newline at end of file
diff --git a/app/services/git/providers/concerns/rest_client/http_error.rb b/app/services/git/providers/concerns/rest_client/http_error.rb
new file mode 100644
index 0000000000..6a267fa968
--- /dev/null
+++ b/app/services/git/providers/concerns/rest_client/http_error.rb
@@ -0,0 +1,54 @@
+# Class to handle generic, raw HTTP errors that can occur when making the REST request
+class Git::Providers::Concerns::RestClient::HTTPError < StandardError
+ include Git::Providers::Abstract::Error
+
+ def initialize(message = nil, response_status: nil, response_body: nil)
+ @response_status = response_status
+ @response_body = response_body
+ super(message || default_message)
+ end
+
+ def self.from_response(response)
+ klass = case response.code.to_i
+ when 401 then Unauthorized
+ when 403 then Forbidden
+ when 404 then NotFound
+ when 409 then Conflict
+ when 422 then UnprocessableEntity
+ when 500..599 then ServerError
+ else self
+ end
+ klass.new(
+ response_status: response.code.to_i,
+ response_body: response.body
+ )
+ end
+
+ private
+
+ attr_reader :response_status, :response_body
+
+ def default_message
+ "REST API error (#{response_status}): #{response_body}"
+ end
+
+ class Unauthorized < self
+ end
+
+ # Mark 403, 404, 409 and 422 as client errors, so that we can discard them immediately.
+ # Not including 401 (unauthorized) as it is handled specifically (invalidate the token).
+ # Not including 429 (rate limit) as it worth retrying for the specific case.
+ class Forbidden < self
+ include Git::Providers::Abstract::ClientError
+ end
+ class NotFound < self
+ include Git::Providers::Abstract::ClientError
+ end
+ class Conflict < self
+ include Git::Providers::Abstract::ClientError
+ end
+ class UnprocessableEntity < self
+ include Git::Providers::Abstract::ClientError
+ end
+ class ServerError < self; end
+end
diff --git a/app/services/git/providers/concerns/rest_client/invalid_response_error.rb b/app/services/git/providers/concerns/rest_client/invalid_response_error.rb
new file mode 100644
index 0000000000..4a94a2c5a9
--- /dev/null
+++ b/app/services/git/providers/concerns/rest_client/invalid_response_error.rb
@@ -0,0 +1,7 @@
+# The server returned a successful HTTP status, but the response body wasn't the valid
+# JSON we expected; distinct from HTTPError, which represents a response the server
+# explicitly flagged as an error via its status code. Tagged Error: because this can
+# still be transient (a proxy returning malformed output, a truncated response...).
+class Git::Providers::Concerns::RestClient::InvalidResponseError < StandardError
+ include Git::Providers::Abstract::Error
+end
diff --git a/app/services/git/providers/concerns/rest_client/network_error.rb b/app/services/git/providers/concerns/rest_client/network_error.rb
new file mode 100644
index 0000000000..ac9295f13f
--- /dev/null
+++ b/app/services/git/providers/concerns/rest_client/network_error.rb
@@ -0,0 +1,6 @@
+# Network-level failure (DNS, connection refused, timeout...); distinct from HTTPError,
+# which represents a response actually received from the server. Tagged Error: because
+# a network failure is transient, so it follows the same generic retry_on.
+class Git::Providers::Concerns::RestClient::NetworkError < StandardError
+ include Git::Providers::Abstract::Error
+end
diff --git a/app/services/git/providers/concerns/rest_client/tree_item.rb b/app/services/git/providers/concerns/rest_client/tree_item.rb
new file mode 100644
index 0000000000..ffb5865725
--- /dev/null
+++ b/app/services/git/providers/concerns/rest_client/tree_item.rb
@@ -0,0 +1,10 @@
+# Default structure to represent the git tree of a repository
+# Override in your concrete provider implementation if key mapping is different
+Git::Providers::Concerns::RestClient::TreeItem = Struct.new(:path, :sha, keyword_init: true) do
+ def self.from_json(json)
+ new(
+ path: json['path'],
+ sha: json['sha']
+ )
+ end
+end
\ No newline at end of file
diff --git a/app/services/git/providers/forgejo.rb b/app/services/git/providers/forgejo.rb
new file mode 100644
index 0000000000..225b847104
--- /dev/null
+++ b/app/services/git/providers/forgejo.rb
@@ -0,0 +1,179 @@
+class Git::Providers::Forgejo < Git::Providers::Abstract
+ include Git::Providers::Concerns::RestClient
+
+ DEFAULT_ENDPOINT = 'https://codeberg.org/api/v1'.freeze
+ COMMIT_BATCH_SIZE = 75
+
+ class InvalidEndpoint < StandardError
+ include Git::Providers::Abstract::InvalidEndpoint
+ end
+
+ class EndpointUnreachable < StandardError
+ include Git::Providers::Abstract::EndpointUnreachable
+ include Git::Providers::Abstract::Error
+ end
+
+ class Unauthorized < StandardError
+ include Git::Providers::Abstract::Unauthorized
+ end
+
+ class RepositoryNotFound < StandardError
+ include Git::Providers::Abstract::RepositoryNotFound
+ end
+
+ class RepositoryForbidden < StandardError
+ include Git::Providers::Abstract::RepositoryForbidden
+ end
+
+ class BranchNotFound < StandardError
+ include Git::Providers::Abstract::BranchNotFound
+ end
+
+ class BranchProtected < StandardError
+ include Git::Providers::Abstract::BranchProtected
+ end
+
+ def url
+ base_url = endpoint.gsub(%r{/api/v1\z}, '')
+ "#{base_url}/#{repository}"
+ end
+
+ def create_file(path, content)
+ batch << {
+ operation: 'create',
+ path: path,
+ content: Base64.strict_encode64(content)
+ }
+ end
+
+ def update_file(path, previous_path, content)
+ item = tree_item_at_path(previous_path) || tree_item_at_path(path)
+ # If the file has been deleted from repository and does not exist anymore, re-create it
+ return create_file(path, content) if item.nil?
+ operation = {
+ operation: 'update',
+ path: path,
+ content: Base64.strict_encode64(content),
+ sha: item.sha
+ }
+ operation[:from_path] = previous_path if previous_path.present? && previous_path != path
+ batch << operation
+ end
+
+ def destroy_file(path)
+ item = tree_item_at_path(path)
+ return if item.nil?
+ batch << {
+ operation: 'delete',
+ path: path,
+ sha: item.sha
+ }
+ end
+
+ def update_theme!
+ # Currently, there is no way to update a submodule with forgejo API
+ nil
+ end
+
+ def init_from_template(name)
+ raise NoMethodError, "You must implement the `init_from_template` method in #{self.class.name}"
+ end
+
+ def update_secrets(secrets)
+ raise NoMethodError, "You must implement the `update_secrets` method in #{self.class.name}"
+ end
+
+ def push(commit_message)
+ return if batch.empty?
+ payload = {
+ branch: branch,
+ message: commit_message,
+ files: batch
+ }
+ rest_request(:post, "/repos/#{repository}/contents", payload)
+ reset_tree_cache!
+ true
+ end
+
+ def computed_sha(string)
+ OpenSSL::Digest::SHA1.hexdigest "blob #{string.bytesize}\x00#{string}"
+ end
+
+ def git_sha(path)
+ return if path.nil?
+ tree_item_at_path(path)&.sha
+ end
+
+ def files_in_the_repository
+ @files_in_the_repository ||= tree.map(&:path)
+ end
+
+ protected
+
+ def check_endpoint!
+ json = get('/version')
+ raise InvalidEndpoint, "Forgejo endpoint does not point to a valid Forgejo/Gitea instance" unless json.key?('version')
+ rescue HTTPError::Unauthorized => e
+ raise Unauthorized, e.message
+ rescue HTTPError, InvalidResponseError => e
+ raise InvalidEndpoint, "Forgejo endpoint does not point to a valid Forgejo/Gitea instance (#{e.message})"
+ rescue NetworkError => e
+ raise EndpointUnreachable, "Forgejo endpoint is unreachable: #{endpoint} (#{e.message})"
+ end
+
+ def check_repository_push_access!
+ json = get("/repos/#{repository}")
+ unless json.dig('permissions', 'push')
+ raise RepositoryForbidden, "Token does not have push access to #{repository}"
+ end
+ rescue HTTPError::Unauthorized => e
+ raise Unauthorized, e.message
+ rescue HTTPError::Forbidden => e
+ raise RepositoryForbidden, e.message
+ rescue HTTPError::NotFound
+ raise RepositoryNotFound, "Repository not found on the Forgejo instance"
+ rescue NetworkError => e
+ raise EndpointUnreachable, "Forgejo endpoint is unreachable: #{endpoint} (#{e.message})"
+ end
+
+ def check_branch_push_access!
+ target_branch = branch
+ json = get("/repos/#{repository}/branches/#{target_branch}")
+ unless json['user_can_push']
+ raise BranchProtected, "Branch '#{target_branch}' is protected and token cannot push to it"
+ end
+ rescue HTTPError::NotFound
+ raise BranchNotFound, "Branch '#{target_branch}' does not exist in repository #{repository}"
+ rescue NetworkError => e
+ raise EndpointUnreachable, "Forgejo endpoint is unreachable: #{endpoint} (#{e.message})"
+ end
+
+ # Get the complete git tree (paginated by forgejo API)
+ def tree
+ @tree ||= begin
+ items = []
+ page = 1
+ loop do
+ json = get("/repos/#{repository}/git/trees/#{branch}", recursive: true, page: page)
+ entries = json['tree'] || []
+ items += entries.select { |entry| entry['type'] == 'blob' }.map { |entry| TreeItem.from_json(entry) }
+ break unless json['truncated']
+ page += 1
+ end
+ items
+ rescue NetworkError
+ # A transient network failure mid-pagination should not abort the whole read:
+ # degrade gracefully and return whatever page(s) were fetched so far.
+ items
+ end
+ end
+
+ # Required authentication headers for forgejo API requests
+ def request_headers
+ {
+ 'Authorization' => "token #{access_token}",
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json'
+ }
+ end
+end
\ No newline at end of file
diff --git a/app/services/git/providers/github.rb b/app/services/git/providers/github.rb
index bfd924f8f2..35300256b3 100644
--- a/app/services/git/providers/github.rb
+++ b/app/services/git/providers/github.rb
@@ -1,13 +1,81 @@
class Git::Providers::Github < Git::Providers::Abstract
BASE_URL = "https://github.com".freeze
COMMIT_BATCH_SIZE = 20
+ WORKFLOWS_PATH_PREFIX = '.github/workflows/'.freeze
+
+ # same as Octokit::Repository::NAME_WITH_OWNER_PATTERN stricter than our generic one in abstract
+ REPOSITORY_FORMAT = %r{\A[\w.-]+/[\w.-]+\z}i.freeze
include WithSecrets
+ # tag native gem class with our custom error class
+ Octokit::Error.include Git::Providers::Abstract::Error
+ [
+ Octokit::BadRequest,
+ Octokit::NotFound,
+ Octokit::BranchNotProtected,
+ Octokit::MethodNotAllowed,
+ Octokit::NotAcceptable,
+ Octokit::Conflict,
+ Octokit::Deprecated,
+ Octokit::UnsupportedMediaType,
+ Octokit::UnprocessableEntity,
+ Octokit::UnavailableForLegalReasons,
+ Octokit::TooLargeContent,
+ Octokit::RepositoryUnavailable,
+ Octokit::UnverifiedEmail,
+ Octokit::AccountSuspended,
+ Octokit::BillingIssue,
+ Octokit::SAMLProtected,
+ Octokit::InstallationSuspended
+ ].each { |klass| klass.include Git::Providers::Abstract::ClientError }
+
+ class Unauthorized < StandardError
+ include Git::Providers::Abstract::Unauthorized
+ end
+
+ class RepositoryNotFound < StandardError
+ include Git::Providers::Abstract::RepositoryNotFound
+ end
+
+ class RepositoryForbidden < StandardError
+ include Git::Providers::Abstract::RepositoryForbidden
+ end
+
+ class BranchNotFound < StandardError
+ include Git::Providers::Abstract::BranchNotFound
+ end
+
+ class BranchProtected < StandardError
+ include Git::Providers::Abstract::BranchProtected
+ end
+
+ class WorkflowsForbidden < StandardError
+ include Git::Providers::Abstract::WorkflowsForbidden
+ end
+
def url
"#{BASE_URL}/#{repository}"
end
+ def check_repository_push_access!
+ repo = client.repository(repository)
+ raise RepositoryForbidden, "Token does not have push access to #{repository}" unless repo[:permissions][:push]
+ rescue Octokit::Unauthorized => e
+ raise Unauthorized, e.message
+ rescue Octokit::NotFound => e
+ raise RepositoryNotFound, e.message
+ rescue Octokit::Forbidden => e
+ raise RepositoryForbidden, e.message
+ end
+
+ def check_branch_push_access!
+ branch_sha
+ raise BranchProtected, "Branch is protected and the token cannot push to it" if branch_protected_for_token?
+ rescue Octokit::NotFound => e
+ raise BranchNotFound, e.message
+ end
+
def create_file(path, content)
batch << {
path: path,
@@ -20,10 +88,8 @@ def create_file(path, content)
def update_file(path, previous_path, content)
previous_path_file = tree_item_at_path(previous_path)
new_path_file = tree_item_at_path(path)
- # En cas de dissonnance entre l'analyzer et le provider, on raise une erreur
- if previous_path_file.nil? && new_path_file.nil?
- raise "File to update does not exist on Git (repository: #{repository}, previous_path: #{previous_path}, path: #{path})"
- end
+ # if the file has been deleted from repository and does not exist anymore, re-create it
+ return create_file(path, content) if previous_path_file.nil? && new_path_file.nil?
if previous_path_file.present?
# Delete previous file
batch << {
@@ -78,13 +144,10 @@ def init_from_template(name)
end
def push(commit_message)
- return if !valid? || batch.empty?
+ return if batch.empty?
commit = create_commit_from_batch(batch, commit_message)
- client.update_branch repository, default_branch, commit[:sha]
- # The repo changed, invalidate the tree
- @tree = nil
- @tree_items_by_path = nil
- #
+ client.update_branch repository, branch, commit[:sha]
+ reset_tree_cache!
true
end
@@ -107,6 +170,22 @@ def create_sub_commit(sub_batch, sub_commit_message, base_tree_sha, base_commit_
puts "Creating commit with #{sub_batch.size} files."
new_tree = client.create_tree repository, sub_batch, base_tree: base_tree_sha
client.create_commit repository, sub_commit_message, new_tree[:sha], base_commit_sha
+ rescue Octokit::Forbidden => e
+ raise workflows_forbidden_error(e) if workflows_permission_issue?(sub_batch, e)
+ raise e
+ end
+
+ # If we need to write to .github/workflows/, needed to write deuxfleurs.yml
+ def workflows_permission_issue?(sub_batch, error)
+ error.message.include?('Resource not accessible by personal access token') &&
+ sub_batch.any? { |entry| entry[:path].to_s.start_with?(WORKFLOWS_PATH_PREFIX) }
+ end
+
+ def workflows_forbidden_error(original_error)
+ WorkflowsForbidden.new(
+ "Token is missing the \"Workflows\" permission required to write under " \
+ "#{WORKFLOWS_PATH_PREFIX} (#{original_error.message})"
+ )
end
def computed_sha(string)
@@ -131,12 +210,31 @@ def client
@client ||= Octokit::Client.new access_token: access_token
end
- def default_branch
- @default_branch ||= branch.presence || 'main'
+ def branch_sha
+ @branch_sha ||= begin
+ response = client.branch(repository, branch)
+ # special case: branch was renamed
+ if response[:name] != branch
+ raise BranchNotFound, "Branch '#{branch}' no longer exists on GitHub (renamed to '#{response[:name]}')"
+ end
+ response[:commit][:sha]
+ end
end
- def branch_sha
- @branch_sha ||= client.branch(repository, default_branch)[:commit][:sha]
+ def branch_protected_for_token?
+ protection = client.branch_protection(repository, branch)
+ return false if protection.nil?
+ return true if protection[:required_pull_request_reviews].present?
+ restrictions = protection[:restrictions]
+ restrictions.present? && restrictions[:users].none? { |user| user[:login] == token_login }
+ rescue Octokit::Forbidden
+ # can occur if the token does not have permission to access branch protection
+ # in that case, we assume the branch is not protected
+ false
+ end
+
+ def token_login
+ @token_login ||= client.user[:login]
end
def tree_item_at_path(path)
diff --git a/app/services/git/providers/gitlab.rb b/app/services/git/providers/gitlab.rb
index 67144b8f15..6cf9955683 100644
--- a/app/services/git/providers/gitlab.rb
+++ b/app/services/git/providers/gitlab.rb
@@ -2,6 +2,48 @@ class Git::Providers::Gitlab < Git::Providers::Abstract
DEFAULT_ENDPOINT = 'https://gitlab.com/api/v4'.freeze
COMMIT_BATCH_SIZE = 75
+ # gitlab allows numerous / in repository names (for groups/sub-groups)
+ REPOSITORY_FORMAT = %r{\A[^/\s]+(?:/[^/\s]+)+\z}.freeze
+
+ # tag native gem class with our custom error class
+ Gitlab::Error::Error.include Git::Providers::Abstract::Error
+ [
+ Gitlab::Error::BadRequest,
+ Gitlab::Error::Forbidden,
+ Gitlab::Error::NotFound,
+ Gitlab::Error::MethodNotAllowed,
+ Gitlab::Error::NotAcceptable,
+ Gitlab::Error::Conflict,
+ Gitlab::Error::Unprocessable
+ ].each { |klass| klass.include Git::Providers::Abstract::ClientError }
+
+
+ class Unauthorized < StandardError
+ include Git::Providers::Abstract::Unauthorized
+ end
+
+ # for generics, no tagging and handle for each context
+ class RepositoryNotFound < StandardError
+ include Git::Providers::Abstract::RepositoryNotFound
+ end
+
+ class BranchNotFound < StandardError
+ include Git::Providers::Abstract::BranchNotFound
+ end
+ class BranchProtected < StandardError
+ include Git::Providers::Abstract::BranchProtected
+ end
+ class RepositoryForbidden < StandardError
+ include Git::Providers::Abstract::RepositoryForbidden
+ end
+ class InvalidEndpoint < StandardError
+ include Git::Providers::Abstract::InvalidEndpoint
+ end
+ class EndpointUnreachable < StandardError
+ include Git::Providers::Abstract::EndpointUnreachable
+ include Git::Providers::Abstract::Error
+ end
+
def url
base_url = endpoint.gsub("/api/v4", "")
"#{base_url}/#{repository}"
@@ -16,8 +58,10 @@ def create_file(path, content)
end
def update_file(path, previous_path, content)
- file = find previous_path
- if file.present? && previous_path != path
+ previous_path_file = tree_item_at_path(previous_path)
+ # if the file has been deleted from repository and does not exist anymore, re-create it
+ return create_file(path, content) if previous_path_file.nil? && tree_item_at_path(path).nil?
+ if previous_path_file.present? && previous_path != path
batch << {
action: 'move',
file_path: path,
@@ -32,8 +76,7 @@ def update_file(path, previous_path, content)
end
def destroy_file(path)
- file = find path
- return if file.nil?
+ return if tree_item_at_path(path).nil?
batch << {
action: 'delete',
file_path: path,
@@ -60,11 +103,12 @@ def update_secrets(secrets)
end
def push(commit_message)
- return if !valid? || batch.empty?
+ return if batch.empty?
client.create_commit repository,
branch,
commit_message,
batch
+ reset_tree_cache!
true
end
@@ -72,46 +116,60 @@ def computed_sha(string)
OpenSSL::Digest::SHA256.hexdigest string
end
- # https://gitlab.com/gitlab-org/gitlab/-/issues/23504
- # TODO : Il faudrait, comme sur GitHub, stocker le tree pour éviter N requêtes pour N objets.
def git_sha(path)
- begin
- file = find path
- sha = file['content_sha256']
- rescue
- sha = nil
- end
- sha
+ return if path.nil?
+ tree_item_at_path(path)&.id
end
- def valid?
- return false unless super
- begin
- client.project(repository)
- true
- rescue Gitlab::Error::Unauthorized
- git_repository.website.invalidate_access_token!
- false
+ def check_endpoint!
+ response = client.get('/version')
+ unless response.respond_to?(:version) && response.version.present?
+ raise InvalidEndpoint, "Gitlab endpoint does not point to a valid Gitlab instance"
end
+ # Normal without token provided; it is the expected response for a GitLab instance
+ rescue Gitlab::Error::Unauthorized, Gitlab::Error::Forbidden
+ nil
+ # gem raise Gitlab::Error::Parsing if response is not JSON
+ rescue Gitlab::Error::ResponseError, Gitlab::Error::Parsing => e
+ raise InvalidEndpoint, "Gitlab endpoint does not point to a valid Gitlab instance (#{e.message})"
+ # (temporary) network error
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
+ SocketError, Timeout::Error, EOFError => e
+ raise EndpointUnreachable, "Gitlab endpoint is unreachable: #{endpoint} (#{e.message})"
end
- def branch
- super.present? ? super
- : 'main'
+ def check_repository_push_access!
+ verify_token!
+ check_repository_access_for_this_repository!
+ end
+
+ def check_branch_push_access!
+ # check branch existence
+ begin
+ client.branch(repository, branch)
+ rescue Gitlab::Error::NotFound => e
+ raise BranchNotFound.new(e.response_message)
+ end
+ # Checks that the branch is not protected with a higher access level than the token has
+ begin
+ protected = client.protected_branch(repository, branch)
+ min_push_level = protected.push_access_levels
+ .map(&:access_level)
+ .min || 0
+ if token_access_level < min_push_level
+ raise BranchProtected, "Branch '#{branch}' is protected and the token's role is not high enough to push to it"
+ end
+ rescue Gitlab::Error::NotFound
+ # No protected branch, all good, nothing to do
+ end
end
- # TODO
def files_in_the_repository
- super
+ @files_in_the_repository ||= tree.map(&:path)
end
protected
- def endpoint
- @endpoint.blank? ? DEFAULT_ENDPOINT
- : @endpoint
- end
-
def client
@client ||= Gitlab.client(
endpoint: endpoint,
@@ -119,12 +177,55 @@ def client
)
end
- def find(path)
- client.get_file repository,
- path,
- branch
- rescue
- nil
+ # Invalidates token only if it invalid or expired
+ def verify_token!
+ token_scopes
+ rescue Gitlab::Error::Unauthorized => e
+ raise Unauthorized, e.message
+ end
+
+ # Check the validity of the token and raise RepositoryForbidden is the gem raise Unauthorized
+ def check_repository_access_for_this_repository!
+ unless token_scopes.include?('api')
+ raise RepositoryForbidden.new('Token must have the "api" scope to push to this repository')
+ end
+ # 30 = Developer (push), 40 = Maintainer, 50 = Owner
+ if token_access_level < 30
+ raise RepositoryForbidden.new('Token does not have push access to this repository, role must be at least Developer')
+ end
+ rescue Gitlab::Error::NotFound => e
+ raise RepositoryNotFound.new(e.response_message)
+ rescue Gitlab::Error::Unauthorized => e
+ raise RepositoryForbidden.new(e.response_message)
+ rescue RepositoryForbidden => e
+ raise e
+ end
+
+ def token_scopes
+ @token_scopes ||= client.get('/personal_access_tokens/self').scopes
+ end
+
+ def token_access_level
+ @token_access_level ||= begin
+ project = client.project(repository)
+ project_access = project.permissions&.project_access&.access_level
+ group_access = project.permissions&.group_access&.access_level
+ project_access || group_access || 0
+ end
+ end
+
+
+ def tree
+ @tree ||= begin
+ items = []
+ response = client.tree(repository, ref: branch, recursive: true, per_page: 100)
+ items += response.select { |item| item.type == 'blob' }
+ while response.has_next_page?
+ response = response.next_page
+ items += response.select { |item| item.type == 'blob' }
+ end
+ items
+ end
end
-end
+end
\ No newline at end of file
diff --git a/app/services/git/repository.rb b/app/services/git/repository.rb
index 29073b4186..420c3d11eb 100644
--- a/app/services/git/repository.rb
+++ b/app/services/git/repository.rb
@@ -32,14 +32,18 @@ def computed_sha(string)
provider.computed_sha(string)
end
- def previous_sha(git_file)
- provider.previous_sha(git_file)
- end
-
def git_sha(path)
provider.git_sha path
end
+ def check_repository_access!
+ provider.check_repository_access!
+ end
+
+ def check_repository_format!
+ provider.check_repository_format!
+ end
+
def valid?
provider.valid?
end
diff --git a/app/views/admin/communication/websites/settings/technical.html.erb b/app/views/admin/communication/websites/settings/technical.html.erb
index 9585309c51..a08808ebca 100644
--- a/app/views/admin/communication/websites/settings/technical.html.erb
+++ b/app/views/admin/communication/websites/settings/technical.html.erb
@@ -1,6 +1,7 @@
<% content_for :title, t('admin.communication.website.technical.label') %>
<%= simple_form_for [:admin, @website] do |f| %>
+ <%= hidden_field_tag :_return_to, 'technical' %>
<%= f.simple_fields_for :localizations, @l10n do |lf| %>
<%= f.error_notification %>
<%= f.error_notification message: f.object.errors[:base].to_sentence if f.object.errors[:base].present? %>
@@ -18,20 +19,51 @@
<%= osuny_panel t('communication.website.git') do %>
- <%= f.input :git_provider, include_blank: false %>
- <%= f.input :git_endpoint %>
- <%= f.input :access_token,
- as: :string,
- placeholder: masked_string(f.object.access_token),
- hint: t("simple_form.hints.communication_website.access_token_#{f.object.access_token.blank? ? 'without' : 'with'}_existing").html_safe,
- input_html: {
- autocomplete: 'access_token',
- role: 'presentation',
- value: ''
- }
- %>
- <%= f.input :repository %>
- <%= f.input :git_branch %>
+ <% if @website.access_token.present? && !@website.git_access_errors? %>
+ <%= link_to technical_admin_communication_website_path(id: @website.id, website_id: nil, check_git_access: true),
+ class: button_classes('btn-xs position-absolute top-0 end-0 m-0'),
+ data: { 'hide-on-change': 'git_access_fields' } do %>
+ <%= lucide_icon :plug, width: 16 %>
+ <%= t('admin.communication.website.technical.check_git_access.button') %>
+ <% end %>
+ <% end %>
+
+ <%= f.input :git_provider,
+ include_blank: false,
+ input_html: { data: { 'conditional': true }, id: 'git_provider' } %>
+
+ <%= f.input :git_endpoint,
+ placeholder: f.object.git_provider_default_endpoint,
+ input_html: {
+ data: {
+ 'placeholder-source': 'git_provider',
+ 'placeholder-map': Communication::Website.git_provider_default_endpoints.to_json
+ }
+ } %>
+
+ <%= f.input :access_token,
+ as: :string,
+ placeholder: masked_string(f.object.access_token),
+ hint: t("simple_form.hints.communication_website.access_token_#{f.object.access_token.blank? ? 'without' : 'with'}_existing").html_safe,
+ input_html: {
+ autocomplete: 'access_token',
+ role: 'presentation',
+ value: f.object.will_save_change_to_access_token? ? f.object.access_token : ''
+ }
+ %>
+ <%= f.input :repository %>
+ <%= f.input :git_branch,
+ placeholder: f.object.git_provider_default_branch,
+ input_html: {
+ data: {
+ 'placeholder-source': 'git_provider',
+ 'placeholder-map': Communication::Website.git_provider_default_branches.to_json
+ }
+ } %>
+
<%= f.input :deployment_status_badge,
as: :string,
input_html: {
@@ -57,7 +89,12 @@
class: "font-monospace fs-5 pt-1"
} %>
- <%= f.input :autoupdate_theme %>
+
+ <%= f.input :autoupdate_theme %>
+
<%= f.input :plausible_url %>
<% end %>
<%= osuny_panel t('communication.website.showcase') do %>
diff --git a/app/views/admin/communication/websites/show.html.erb b/app/views/admin/communication/websites/show.html.erb
index 99be5e9261..181c839205 100644
--- a/app/views/admin/communication/websites/show.html.erb
+++ b/app/views/admin/communication/websites/show.html.erb
@@ -9,7 +9,7 @@
<%= link_to t('communication.website.golive.button'),
production_admin_communication_website_path(@website),
class: button_classes unless @website.in_production %>
- <% if can?(:synchronize, @website) && @desynchronized_generated_git_files.any? %>
+ <% if can?(:synchronize, @website) && @desynchronized_generated_git_files.any? && !@sync_with_git_scheduled %>
<%= link_to synchronize_admin_communication_website_path(@website), method: :post, class: button_classes do %>
<%= t('admin.communication.website.synchronize.button') %>
<%= @desynchronized_generated_git_files.length %>
diff --git a/app/views/mailers/notifications/website_git_access_broken.html.erb b/app/views/mailers/notifications/website_git_access_broken.html.erb
new file mode 100644
index 0000000000..6d08634d5c
--- /dev/null
+++ b/app/views/mailers/notifications/website_git_access_broken.html.erb
@@ -0,0 +1,4 @@
+<%= t('mailers.notifications.website_git_access_broken.text_line_1_html', website: @website) %>
+<%= t('mailers.notifications.website_git_access_broken.text_line_2_html', error: @error_message) %>
+<%= t('mailers.notifications.website_git_access_broken.text_line_3_html', url: @url) %>
+<%= t('mailers.yours') %>
diff --git a/app/views/server/websites/show.html.erb b/app/views/server/websites/show.html.erb
index 39677f8916..2ba94a2d79 100644
--- a/app/views/server/websites/show.html.erb
+++ b/app/views/server/websites/show.html.erb
@@ -12,6 +12,13 @@
data: {
confirm: t('please_confirm.generic')
} %>
+ <%= link_to 'Forcer le renvoi de tous les fichiers',
+ force_resync_with_git_server_website_path(@website),
+ method: :post,
+ class: button_classes,
+ data: {
+ confirm: t('please_confirm.generic')
+ } %>
<%= link_to 'Changer d\'université',
edit_server_website_path(@website),
class: button_classes %>
diff --git a/config/application.sample.yml b/config/application.sample.yml
index fc127c36e6..e5831ecfb5 100644
--- a/config/application.sample.yml
+++ b/config/application.sample.yml
@@ -72,6 +72,10 @@ TEST_GITLAB_BRANCH:
TEST_GITLAB_ENDPOINT:
TEST_GITLAB_REPOSITORY:
TEST_GITLAB_TOKEN:
+TEST_FORGEJO_BRANCH:
+TEST_FORGEJO_ENDPOINT:
+TEST_FORGEJO_REPOSITORY:
+TEST_FORGEJO_TOKEN:
UNSPLASH_ACCESS_KEY:
UNSPLASH_SECRET:
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index f10b5c86b6..0c80e48c8e 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -11,6 +11,7 @@
inflect.irregular 'axis', 'axes'
inflect.irregular 'alumnus', 'alumni'
inflect.irregular 'media', 'medias'
+ inflect.acronym 'HTTP'
end
# These inflection rules are supported but not enabled by default:
diff --git a/config/locales/communication/en.yml b/config/locales/communication/en.yml
index 9b887a879e..0cca70dd63 100644
--- a/config/locales/communication/en.yml
+++ b/config/locales/communication/en.yml
@@ -339,6 +339,21 @@ en:
original_uploaded_file:
already_imported: already imported in media library
too_big: too heavy!
+ communication/website:
+ attributes:
+ git_endpoint:
+ invalid: does not point to a valid instance
+ unreachable: is currently unreachable (try again later)
+ access_token:
+ unauthorized: does not exist, is invalid or has expired
+ forbidden: does not have required permissions to access this repository
+ workflows_forbidden: is missing the fine-grained "Workflows" permission required to write deployment files (.github/workflows/), in addition to the "Contents" permission
+ repository:
+ invalid: is not in the expected format (organization/name)
+ not_found: does not exist or is not accessible with this token (check the specified repository name and the permissions associated with the access token)
+ git_branch:
+ not_found: does not exist (create the branch on the remote repository first)
+ protected: is protected and does not allow write access with this token
communication/website/agenda/event:
attributes:
from_day:
@@ -685,6 +700,9 @@ en:
button: Synchronize
running: Synchronization launched, please be patient...
technical:
+ check_git_access:
+ button: Test connection
+ success: The connection to the Git repository is working correctly
description: Everything related to technical settings
label: Technical
communication:
@@ -901,11 +919,11 @@ en:
communication_media_localization:
internal_description: Complementary description of the image for the media library users. You can write this text to help the media library users to find an image, have complementary informations about it or have special instructions regarding the use of the image.
communication_website:
- access_token_with_existing: Your Github or Gitlab confidential access token.
Leave the field blank if you don't want to change the token.
- access_token_without_existing: Your Github or Gitlab confidential access token. Leave it empty when creating a new website with Deuxfleurs.
+ access_token_with_existing: Your git provider confidential access token.
Leave the field blank if you don't want to change the token.
+ access_token_without_existing: Your git provider confidential access token. Leave it empty when creating a new website with Deuxfleurs.
apache_config_custom_content: The redirects will be added after this content.
archive_content: "If you check this box, the posts, events and exhibitions will be automatically unpublished after the defined time period, and therefore removed from the site. You can also mark a content as « lasting » to prevent it from being unpublished automatically."
- deployment_status_badge: "Badge URL: Github, Gitlab"
+ deployment_status_badge: "Badge URL: Github, Gitlab, , Forgejo
Works only if repository visibility is Public."
feature_agenda: To share events
feature_alerts: To display alert banners
feature_alumni: To display public alumni
@@ -915,7 +933,7 @@ en:
feature_portfolio: To showcase projects
feature_posts: To publish posts, like a media
git_branch: 'If blank, default branch will be used'
- git_endpoint: 'If blank, default will be used (https://github.com or https://gitlab.com/api/v4)'
+ git_endpoint: 'If blank, default instance offered by the git provider will be used'
in_production: Tick this box once the site is deployed on a dedicated domain or sub-domain (not *.osuny.site).
languages: 'If you select one language the website urls will not be prefixed. If you select more than one language the website will then be considered as multilingual, and therefore all urls will be prefixed with the language (/fr, /en)'
plausible_url: Dashboard link generated following the official Plausible documentation.
diff --git a/config/locales/communication/fr.yml b/config/locales/communication/fr.yml
index 63669e2771..7463ca8a13 100644
--- a/config/locales/communication/fr.yml
+++ b/config/locales/communication/fr.yml
@@ -339,6 +339,21 @@ fr:
original_uploaded_file:
already_imported: déjà importé dans la médiathèque
too_big: trop lourd !
+ communication/website:
+ attributes:
+ git_endpoint:
+ invalid: ne pointe pas vers une instance valide
+ unreachable: est actuellement injoignable (réessayez plus tard)
+ access_token:
+ unauthorized: n'existe pas, n'est pas valide ou a expiré
+ forbidden: n'a pas les permissions nécessaires pour accéder à ce dépôt
+ workflows_forbidden: n'a pas la permission "Workflows" nécessaire pour écrire les fichiers de déploiement (.github/workflows/), en plus de la permission "Contents"
+ repository:
+ invalid: n'est pas au format attendu (organisation/nom)
+ not_found: n'existe pas ou n'est pas accessible avec ce token (vérifiez le nom du référentiel indiqué et les permissions associées au jeton d'accès)
+ git_branch:
+ not_found: n'existe pas (créez d'abord la branche sur le dépôt distant)
+ protected: est protégée et ne permet pas l'écriture avec ce token
communication/website/agenda/event:
attributes:
from_day:
@@ -685,6 +700,9 @@ fr:
button: Synchroniser
running: La synchronisation est lancée, un peu de patience...
technical:
+ check_git_access:
+ button: Tester la connexion
+ success: La connexion au dépôt Git fonctionne correctement.
description: Tout ce qui est lié aux réglages techniques
label: Technique
communication:
@@ -901,11 +919,11 @@ fr:
communication_media_localization:
internal_description: Description complémentaire de l'image. Vous pouvez donner des informations complémentaires sur l'image, utiles pour la recherche et indiquer des instructions spécifiques d'usage pour les utilisateur·rices de la médiathèque.
communication_website:
- access_token_with_existing: Votre jeton d'accès Github ou Gitlab confidentiel.
Laisser le champ vide pour ne pas le modifier.
- access_token_without_existing: Votre jeton d'accès Github ou Gitlab confidentiel, à laisser vide lors de la création d'un site avec Deuxfleurs.
+ access_token_with_existing: Votre jeton d'accès confidentiel de fournisseur git.
Laisser le champ vide pour ne pas le modifier.
+ access_token_without_existing: Votre jeton d'accès confidentiel de fournisseur git, à laisser vide lors de la création d'un site avec Deuxfleurs.
apache_config_custom_content: Les redirections seront ajoutées à la suite de ce contenu.
archive_content: "Si vous cochez cette case, les actualités, événements et expositions seront automatiquement dépubliés après la période définie, et donc supprimés du site. Vous pouvez également définir un contenu comme « pérenne » pour qu'il ne soit pas dépublié automatiquement."
- deployment_status_badge: "URL du badge : Github, Gitlab"
+ deployment_status_badge: "URL du badge : Github, Gitlab, Forgejo
Fonctionne uniquement si la visibilité du dépôt est Public."
feature_agenda: Pour partager des événements
feature_alerts: Pour afficher des bandeaux d'alerte
feature_alumni: Pour présenter les alumni publiquement
@@ -915,7 +933,7 @@ fr:
feature_portfolio: Pour présenter des projets
feature_posts: Pour publier des actualités, comme un média
git_branch: 'Laisser vide pour la branche par défaut'
- git_endpoint: 'Laisser vide pour les valeurs par défaut (https://github.com ou https://gitlab.com/api/v4)'
+ git_endpoint: 'Laisser vide pour l''instance hébergée proposée par le Fournisseur Git'
in_production: Cochez cette case une fois le site déployé sur un domaine ou un sous-domaine dédié (pas *.osuny.site).
languages: 'Si vous sélectionnez une seule langue les urls ne seront pas préfixées. Si vous en sélectionnez plusieurs le site sera considéré comme multilingue et donc toutes les urls seront préfixées avec la langue (/fr, /en)'
plausible_url: Lien de partage généré selon la documentation officielle Plausible.
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 9b89adae69..0b1101cc40 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -523,6 +523,21 @@ en:
body_1_html: "A new user (%{username}) just registered on instance %{instance}."
body_2_html: "Click here to see the account."
subject: "A new user just registered: %{mail}"
+ website_git_access_broken:
+ subject: "Git synchronization interrupted for \"%{website}\""
+ text_line_1_html: "The synchronization of website \"%{website}\" with its Git repository has failed and been interrupted."
+ text_line_2_html: "Error encountered: %{error}"
+ text_line_3_html: "Check the repository configuration (existence, branch, permissions, endpoint) by clicking here."
+ errors:
+ invalid_repository: "The configured repository identifier \"%{repository}\" is not in a valid format."
+ unreachable: "The configured Git endpoint (%{endpoint}) is currently unreachable."
+ invalid_endpoint: "The configured Git endpoint does not point to a valid instance."
+ unauthorized: "The access token no longer exists, is invalid, or has expired."
+ repository_not_found: "Repository \"%{repository}\" no longer exists or is no longer accessible with this token."
+ repository_forbidden: "The access token no longer has permission to push to repository \"%{repository}\"."
+ branch_not_found: "Branch \"%{branch}\" no longer exists in the repository."
+ branch_protected: "Branch \"%{branch}\" is now protected and no longer allows write access with this token."
+ workflows_forbidden: "The access token is missing the \"Workflows\" permission required to write deployment files."
website_invalid_access_token:
subject: "Expired access token for \"%{website}\""
text_line_1_html: "The access token used for the website \"%{website}\" has expired and does not allow the website to be updated anymore."
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 5d6d02f4aa..2877c46c1f 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -524,6 +524,21 @@ fr:
body_1_html: "Un nouvel utilisateur (%{username}) vient de s'enregistrer sur l'instance %{instance}."
body_2_html: "Cliquez ici pour voir son compte."
subject: "Un nouvel utilisateur vient de s'inscrire : %{mail}"
+ website_git_access_broken:
+ subject: Synchronisation Git interrompue pour « %{website} »
+ text_line_1_html: La synchronisation du site « %{website} » avec son dépôt Git a échoué et a été interrompue.
+ text_line_2_html: "Erreur rencontrée : %{error}"
+ text_line_3_html: "Vérifiez la configuration du dépôt (existence, branche, permissions, endpoint) en cliquant ici."
+ errors:
+ invalid_repository: "L'identifiant de dépôt configuré « %{repository} » n'est pas dans un format valide."
+ unreachable: "L'endpoint Git configuré (%{endpoint}) est actuellement injoignable."
+ invalid_endpoint: "L'endpoint Git configuré ne pointe pas vers une instance valide."
+ unauthorized: "Le jeton d'accès n'existe plus, n'est plus valide ou a expiré."
+ repository_not_found: "Le dépôt « %{repository} » n'existe plus ou n'est plus accessible avec ce jeton."
+ repository_forbidden: "Le jeton d'accès n'a plus la permission de pousser vers le dépôt « %{repository} »."
+ branch_not_found: "La branche « %{branch} » n'existe plus dans le dépôt."
+ branch_protected: "La branche « %{branch} » est désormais protégée et ne permet plus l'écriture avec ce jeton."
+ workflows_forbidden: "Le jeton d'accès n'a pas la permission « Workflows » nécessaire pour écrire les fichiers de déploiement."
website_invalid_access_token:
subject: Jeton d'accès expiré pour « %{website} »
text_line_1_html: Le jeton d'accès utilisé pour le site « %{website} » a expiré et ne permet plus la mise à jour du site.
diff --git a/config/locales/server_admin/en.yml b/config/locales/server_admin/en.yml
index 9d708cbf78..a8e4f8a335 100644
--- a/config/locales/server_admin/en.yml
+++ b/config/locales/server_admin/en.yml
@@ -57,6 +57,7 @@ en:
details: detail
events_count: "%{count} events"
force_clean_and_rebuild: Force clean&rebuild of every websites
+ force_resync_with_git_notice: All files have been marked as desynchronized. Use the "Synchronize" button on the website page to push them.
git_repo:
full: Git Repository
short: Repository
diff --git a/config/locales/server_admin/fr.yml b/config/locales/server_admin/fr.yml
index 87fdb9a890..6e7d6723ca 100644
--- a/config/locales/server_admin/fr.yml
+++ b/config/locales/server_admin/fr.yml
@@ -57,6 +57,7 @@ fr:
details: détail
events_count: "%{count} événements"
force_clean_and_rebuild: Forcer le clean&rebuild de tous les sites
+ force_resync_with_git_notice: Tous les fichiers ont été marqués comme désynchronisés. Utilisez le bouton "Synchroniser" sur la page du site pour lancer l'envoi.
git_repo:
full: Référentiel Git
short: Référentiel
diff --git a/config/routes/server.rb b/config/routes/server.rb
index 763bf9df10..941d1b98b3 100644
--- a/config/routes/server.rb
+++ b/config/routes/server.rb
@@ -13,6 +13,7 @@
member do
post :analyse
post :clean_and_rebuild
+ post :force_resync_with_git
post :sync_theme_version
post :unlock_for_background_jobs
post :update_theme
diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb
index 14aa46efa4..ce898d2187 100644
--- a/spec/support/vcr.rb
+++ b/spec/support/vcr.rb
@@ -8,6 +8,8 @@
c.filter_sensitive_data('') { ENV['TEST_GITHUB_REPOSITORY'] }
c.filter_sensitive_data('') { ENV['TEST_GITLAB_TOKEN'] }
c.filter_sensitive_data('') { ENV['TEST_GITLAB_REPOSITORY'] }
+ c.filter_sensitive_data('') { ENV['TEST_FORGEJO_TOKEN'] }
+ c.filter_sensitive_data('') { ENV['TEST_FORGEJO_REPOSITORY'] }
c.filter_sensitive_data('') do |interaction|
interaction.request.headers['Authorization'].try(:first)
end
diff --git a/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_forgejo.yml b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_forgejo.yml
new file mode 100644
index 0000000000..2ae2441cd3
--- /dev/null
+++ b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_forgejo.yml
@@ -0,0 +1,91 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: "/version"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ User-Agent:
+ - Ruby
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx/1.22.1
+ Date:
+ - Fri, 10 Jul 2026 16:42:25 GMT
+ Content-Type:
+ - application/json;charset=utf-8
+ Content-Length:
+ - '34'
+ Connection:
+ - keep-alive
+ Cache-Control:
+ - max-age=0, private, must-revalidate, no-transform
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ body:
+ encoding: UTF-8
+ string: '{"version":"15.0.3+gitea-1.22.0"}
+
+ '
+ recorded_at: Fri, 10 Jul 2026 16:42:25 GMT
+- request:
+ method: get
+ uri: "/repos/"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Authorization:
+ - ""
+ Content-Type:
+ - application/json
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ User-Agent:
+ - Ruby
+ response:
+ status:
+ code: 401
+ message: Unauthorized
+ headers:
+ Server:
+ - nginx/1.22.1
+ Date:
+ - Fri, 10 Jul 2026 16:42:25 GMT
+ Content-Type:
+ - application/json;charset=utf-8
+ Content-Length:
+ - '72'
+ Connection:
+ - keep-alive
+ Cache-Control:
+ - max-age=0, private, must-revalidate, no-transform
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ body:
+ encoding: UTF-8
+ string: '{"message":"token is required","url":"https://git.nnx.com/api/swagger"}
+
+ '
+ recorded_at: Fri, 10 Jul 2026 16:42:25 GMT
+recorded_with: VCR 6.4.0
diff --git a/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_forgejo_when_token_is_rejected_on_the_version_endpoint.yml b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_forgejo_when_token_is_rejected_on_the_version_endpoint.yml
new file mode 100644
index 0000000000..23d17a055c
--- /dev/null
+++ b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_forgejo_when_token_is_rejected_on_the_version_endpoint.yml
@@ -0,0 +1,49 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: "/version"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Authorization:
+ - ""
+ Content-Type:
+ - application/json
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ User-Agent:
+ - Ruby
+ response:
+ status:
+ code: 401
+ message: Unauthorized
+ headers:
+ Server:
+ - nginx/1.22.1
+ Date:
+ - Fri, 10 Jul 2026 16:42:25 GMT
+ Content-Type:
+ - application/json;charset=utf-8
+ Content-Length:
+ - '72'
+ Connection:
+ - keep-alive
+ Cache-Control:
+ - max-age=0, private, must-revalidate, no-transform
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ body:
+ encoding: UTF-8
+ string: '{"message":"token is required","url":"https://git.nnx.com/api/swagger"}
+
+ '
+ recorded_at: Fri, 10 Jul 2026 16:42:25 GMT
+recorded_with: VCR 6.4.0
diff --git a/test/cassettes/GitRepositoryTest_test_incorrect_credentials_for_github.yml b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_github.yml
similarity index 70%
rename from test/cassettes/GitRepositoryTest_test_incorrect_credentials_for_github.yml
rename to test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_github.yml
index 865e74f66b..200436bfda 100644
--- a/test/cassettes/GitRepositoryTest_test_incorrect_credentials_for_github.yml
+++ b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_github.yml
@@ -2,7 +2,7 @@
http_interactions:
- request:
method: get
- uri: https://api.github.com/repos//branches/main
+ uri: https://api.github.com/repos/
body:
encoding: US-ASCII
string: ''
@@ -10,7 +10,7 @@ http_interactions:
Accept:
- application/vnd.github.v3+json
User-Agent:
- - Octokit Ruby Gem 9.2.0
+ - Octokit Ruby Gem 10.0.0
Content-Type:
- application/json
Authorization:
@@ -22,24 +22,10 @@ http_interactions:
code: 401
message: Unauthorized
headers:
- Date:
- - Mon, 24 Mar 2025 15:57:34 GMT
Content-Type:
- application/json; charset=utf-8
- Content-Length:
- - '95'
X-Github-Media-Type:
- github.v3; format=json
- X-Ratelimit-Limit:
- - '60'
- X-Ratelimit-Remaining:
- - '59'
- X-Ratelimit-Reset:
- - '1742835454'
- X-Ratelimit-Used:
- - '1'
- X-Ratelimit-Resource:
- - core
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
@@ -63,10 +49,15 @@ http_interactions:
- Accept-Encoding, Accept, X-Requested-With
Server:
- github.com
+ Date:
+ - Fri, 10 Jul 2026 16:37:21 GMT
X-Github-Request-Id:
- - D5AA:1B3783:D64838:DAFE09:67E180EE
+ - BC9C:3726CB:1986E5C:1828772:6A511FC1
+ Connection:
+ - close
body:
encoding: UTF-8
- string: '{"message":"Bad credentials","documentation_url":"https://docs.github.com/rest","status":"401"}'
- recorded_at: Mon, 24 Mar 2025 15:57:34 GMT
-recorded_with: VCR 6.3.1
+ string: "{\r\n \"message\": \"Bad credentials\",\r\n \"documentation_url\":
+ \"https://docs.github.com/rest\",\r\n \"status\": \"401\"\r\n}"
+ recorded_at: Fri, 10 Jul 2026 16:37:21 GMT
+recorded_with: VCR 6.4.0
diff --git a/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_gitlab.yml b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_gitlab.yml
new file mode 100644
index 0000000000..58feec203a
--- /dev/null
+++ b/test/cassettes/GitRepositoryTest_test_check_repository_access__invalidates_access_token_for_gitlab.yml
@@ -0,0 +1,123 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: "/version"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Accept:
+ - application/json
+ Content-Type:
+ - application/x-www-form-urlencoded
+ User-Agent:
+ - Gitlab Ruby Gem 6.1.0
+ Private-Token:
+ - wrong access token
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 401
+ message: Unauthorized
+ headers:
+ Date:
+ - Fri, 10 Jul 2026 23:28:14 GMT
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '30'
+ Connection:
+ - keep-alive
+ Server:
+ - cloudflare
+ Cache-Control:
+ - no-cache
+ Nel:
+ - '{"max_age": 0}'
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Gitlab-Meta:
+ - '{"correlation_id":"01KX75RE9MHW2TPHGQ3V1AB7FB","version":"1"}'
+ X-Request-Id:
+ - 01KX75RE9MHW2TPHGQ3V1AB7FB
+ X-Runtime:
+ - '0.007485'
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ Cf-Cache-Status:
+ - DYNAMIC
+ Cf-Ray:
+ - a19357fb7e4b5786-CDG
+ Alt-Svc:
+ - h3=":443"; ma=86400
+ body:
+ encoding: UTF-8
+ string: '{"message":"401 Unauthorized"}'
+ recorded_at: Fri, 10 Jul 2026 23:28:14 GMT
+- request:
+ method: get
+ uri: "/personal_access_tokens/self"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Accept:
+ - application/json
+ Content-Type:
+ - application/x-www-form-urlencoded
+ User-Agent:
+ - Gitlab Ruby Gem 6.1.0
+ Private-Token:
+ - wrong access token
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 401
+ message: Unauthorized
+ headers:
+ Date:
+ - Fri, 10 Jul 2026 23:28:14 GMT
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '30'
+ Connection:
+ - keep-alive
+ Server:
+ - cloudflare
+ Cache-Control:
+ - no-cache
+ Nel:
+ - '{"max_age": 0}'
+ Vary:
+ - Origin
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Gitlab-Meta:
+ - '{"correlation_id":"01KX75REB26HFBXF67QRMCHY2D","version":"1"}'
+ X-Request-Id:
+ - 01KX75REB26HFBXF67QRMCHY2D
+ X-Runtime:
+ - '0.007561'
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ Cf-Cache-Status:
+ - DYNAMIC
+ Cf-Ray:
+ - a19357fbb958dc71-CDG
+ Alt-Svc:
+ - h3=":443"; ma=86400
+ body:
+ encoding: UTF-8
+ string: '{"message":"401 Unauthorized"}'
+ recorded_at: Fri, 10 Jul 2026 23:28:14 GMT
+recorded_with: VCR 6.4.0
diff --git a/test/cassettes/GitRepositoryTest_test_file_creation_on_forgejo.yml b/test/cassettes/GitRepositoryTest_test_file_creation_on_forgejo.yml
new file mode 100644
index 0000000000..2fc86980e8
--- /dev/null
+++ b/test/cassettes/GitRepositoryTest_test_file_creation_on_forgejo.yml
@@ -0,0 +1,181 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: "/version"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ User-Agent:
+ - Ruby
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx/1.22.1
+ Date:
+ - Fri, 10 Jul 2026 15:30:26 GMT
+ Content-Type:
+ - application/json;charset=utf-8
+ Content-Length:
+ - '34'
+ Connection:
+ - keep-alive
+ Cache-Control:
+ - max-age=0, private, must-revalidate, no-transform
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ body:
+ encoding: UTF-8
+ string: '{"version":"15.0.3+gitea-1.22.0"}
+
+ '
+ recorded_at: Fri, 10 Jul 2026 15:30:26 GMT
+- request:
+ method: get
+ uri: "/repos/"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Authorization:
+ - ""
+ Content-Type:
+ - application/json
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ User-Agent:
+ - Ruby
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx/1.22.1
+ Date:
+ - Fri, 10 Jul 2026 15:30:26 GMT
+ Content-Type:
+ - application/json;charset=utf-8
+ Transfer-Encoding:
+ - chunked
+ Connection:
+ - keep-alive
+ Cache-Control:
+ - max-age=0, private, must-revalidate, no-transform
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ body:
+ encoding: UTF-8
+ string: '{"id":17,"owner":{"id":5,"login":"neuronnexion","login_name":"","source_id":0,"full_name":"Neuronnexion","email":"contact@nnx.com","avatar_url":"https://git.nnx.com/avatars/f98397a1a3ddbd151b410802b62a9eb1fb64a003dba80fe4d37f40ce3af1c76b","html_url":"https://git.nnx.com/neuronnexion","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2025-08-01T14:30:25+02:00","restricted":false,"active":false,"prohibit_login":false,"location":"","pronouns":"","website":"https://www.neuronnexion.coop","description":"","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"neuronnexion"},"name":"osuny-forgejo-provider-test","full_name":"","description":"","empty":false,"private":false,"fork":false,"template":false,"parent":null,"mirror":false,"size":29,"language":"","languages_url":"/repos//languages","html_url":"https://git.nnx.com/","url":"/repos/","link":"","ssh_url":"ssh://git@git.nnx.com/.git","clone_url":"https://git.nnx.com/.git","original_url":"","website":"","stars_count":0,"forks_count":0,"watchers_count":2,"open_issues_count":0,"open_pr_counter":0,"release_counter":0,"default_branch":"main","archived":false,"created_at":"2026-07-09T13:27:38+02:00","updated_at":"2026-07-09T17:44:40+02:00","archived_at":"1970-01-01T01:00:00+01:00","permissions":{"admin":true,"push":true,"pull":true},"has_issues":true,"internal_tracker":{"enable_time_tracker":true,"allow_only_contributors_to_track_time":true,"enable_issue_dependencies":true},"has_wiki":true,"has_wiki_contents":false,"wiki_branch":"main","wiki_ssh_url":"ssh://git@git.nnx.com/.wiki.git","wiki_clone_url":"https://git.nnx.com/.wiki.git","globally_editable_wiki":false,"has_pull_requests":true,"has_projects":true,"has_releases":true,"has_packages":true,"has_actions":true,"ignore_whitespace_conflicts":false,"allow_merge_commits":true,"allow_rebase":true,"allow_rebase_explicit":true,"allow_squash_merge":true,"allow_fast_forward_only_merge":true,"allow_rebase_update":true,"default_delete_branch_after_merge":false,"default_merge_style":"merge","default_allow_maintainer_edit":false,"default_update_style":"merge","avatar_url":"","internal":false,"mirror_interval":"","object_format_name":"sha1","mirror_updated":"0001-01-01T00:00:00Z","repo_transfer":null,"topics":[]}
+
+ '
+ recorded_at: Fri, 10 Jul 2026 15:30:26 GMT
+- request:
+ method: get
+ uri: "/repos//branches/main"
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ Authorization:
+ - ""
+ Content-Type:
+ - application/json
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ User-Agent:
+ - Ruby
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx/1.22.1
+ Date:
+ - Fri, 10 Jul 2026 15:30:26 GMT
+ Content-Type:
+ - application/json;charset=utf-8
+ Content-Length:
+ - '782'
+ Connection:
+ - keep-alive
+ Cache-Control:
+ - max-age=0, private, must-revalidate, no-transform
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ body:
+ encoding: ASCII-8BIT
+ string: !binary |-
+ eyJuYW1lIjoibWFpbiIsImNvbW1pdCI6eyJpZCI6ImNjMjExNDg1ZDRhNjEzNTBiZjI0ODMzYTQ2YWZlZmUzMmFmNmZkMDIiLCJtZXNzYWdlIjoiRGVzdHJveWluZyBuZXdfdGVzdC50eHQgZmlsZVxuIiwidXJsIjoiaHR0cHM6Ly9naXQubm54LmNvbS88VEVTVF9GT1JHRUpPX1JFUE9TSVRPUlk+L2NvbW1pdC9jYzIxMTQ4NWQ0YTYxMzUwYmYyNDgzM2E0NmFmZWZlMzJhZjZmZDAyIiwiYXV0aG9yIjp7Im5hbWUiOiJKZWFuIFRyYXVsbMOpIiwiZW1haWwiOiJqLnRyYXVsbGVAbm54LmNvbSIsInVzZXJuYW1lIjoianRyYXVsbGUifSwiY29tbWl0dGVyIjp7Im5hbWUiOiJKZWFuIFRyYXVsbMOpIiwiZW1haWwiOiJqLnRyYXVsbGVAbm54LmNvbSIsInVzZXJuYW1lIjoianRyYXVsbGUifSwidmVyaWZpY2F0aW9uIjp7InZlcmlmaWVkIjpmYWxzZSwicmVhc29uIjoiZ3BnLmVycm9yLm5vdF9zaWduZWRfY29tbWl0Iiwic2lnbmF0dXJlIjoiIiwic2lnbmVyIjpudWxsLCJwYXlsb2FkIjoiIn0sInRpbWVzdGFtcCI6IjIwMjYtMDctMDlUMTc6NDQ6MzgrMDI6MDAiLCJhZGRlZCI6bnVsbCwicmVtb3ZlZCI6bnVsbCwibW9kaWZpZWQiOm51bGx9LCJwcm90ZWN0ZWQiOmZhbHNlLCJyZXF1aXJlZF9hcHByb3ZhbHMiOjAsImVuYWJsZV9zdGF0dXNfY2hlY2siOmZhbHNlLCJzdGF0dXNfY2hlY2tfY29udGV4dHMiOltdLCJ1c2VyX2Nhbl9wdXNoIjp0cnVlLCJ1c2VyX2Nhbl9tZXJnZSI6dHJ1ZSwiZWZmZWN0aXZlX2JyYW5jaF9wcm90ZWN0aW9uX25hbWUiOiIifQo=
+ recorded_at: Fri, 10 Jul 2026 15:30:26 GMT
+- request:
+ method: post
+ uri: "/repos//contents"
+ body:
+ encoding: UTF-8
+ string: '{"branch":"main","message":"Creating test.txt file","files":[{"operation":"create","path":"test.txt","content":"Y29udGVudA=="}]}'
+ headers:
+ Authorization:
+ - ""
+ Content-Type:
+ - application/json
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ User-Agent:
+ - Ruby
+ response:
+ status:
+ code: 201
+ message: Created
+ headers:
+ Server:
+ - nginx/1.22.1
+ Date:
+ - Fri, 10 Jul 2026 15:30:27 GMT
+ Content-Type:
+ - application/json;charset=utf-8
+ Transfer-Encoding:
+ - chunked
+ Connection:
+ - keep-alive
+ Cache-Control:
+ - max-age=0, private, must-revalidate, no-transform
+ X-Content-Type-Options:
+ - nosniff
+ X-Frame-Options:
+ - SAMEORIGIN
+ Strict-Transport-Security:
+ - max-age=31536000; includeSubDomains
+ body:
+ encoding: ASCII-8BIT
+ string: !binary |-
+ eyJmaWxlcyI6W3sibmFtZSI6InRlc3QudHh0IiwicGF0aCI6InRlc3QudHh0Iiwic2hhIjoiNmI1ODRlOGVjZTU2MmViZmZjMTVkMzg4MDhjZDZiOThmYzNkOTdlYSIsImxhc3RfY29tbWl0X3NoYSI6IjdjMDY1NWM3Zjc2NmU4Y2QxNjM2YmUzZDBiZjQ4ZDY3ZmU5YjcxZWMiLCJsYXN0X2NvbW1pdF93aGVuIjoiMjAyNi0wNy0xMFQxNzozMDoyNiswMjowMCIsInR5cGUiOiJmaWxlIiwic2l6ZSI6NywiZW5jb2RpbmciOiJiYXNlNjQiLCJjb250ZW50IjoiWTI5dWRHVnVkQT09IiwidGFyZ2V0IjpudWxsLCJ1cmwiOiI8VEVTVF9GT1JHRUpPX0VORFBPSU5UPi9yZXBvcy88VEVTVF9GT1JHRUpPX1JFUE9TSVRPUlk+L2NvbnRlbnRzL3Rlc3QudHh0P3JlZj1tYWluIiwiaHRtbF91cmwiOiJodHRwczovL2dpdC5ubnguY29tLzxURVNUX0ZPUkdFSk9fUkVQT1NJVE9SWT4vc3JjL2JyYW5jaC9tYWluL3Rlc3QudHh0IiwiZ2l0X3VybCI6IjxURVNUX0ZPUkdFSk9fRU5EUE9JTlQ+L3JlcG9zLzxURVNUX0ZPUkdFSk9fUkVQT1NJVE9SWT4vZ2l0L2Jsb2JzLzZiNTg0ZThlY2U1NjJlYmZmYzE1ZDM4ODA4Y2Q2Yjk4ZmMzZDk3ZWEiLCJkb3dubG9hZF91cmwiOiJodHRwczovL2dpdC5ubnguY29tLzxURVNUX0ZPUkdFSk9fUkVQT1NJVE9SWT4vcmF3L2JyYW5jaC9tYWluL3Rlc3QudHh0Iiwic3VibW9kdWxlX2dpdF91cmwiOm51bGwsIl9saW5rcyI6eyJzZWxmIjoiPFRFU1RfRk9SR0VKT19FTkRQT0lOVD4vcmVwb3MvPFRFU1RfRk9SR0VKT19SRVBPU0lUT1JZPi9jb250ZW50cy90ZXN0LnR4dD9yZWY9bWFpbiIsImdpdCI6IjxURVNUX0ZPUkdFSk9fRU5EUE9JTlQ+L3JlcG9zLzxURVNUX0ZPUkdFSk9fUkVQT1NJVE9SWT4vZ2l0L2Jsb2JzLzZiNTg0ZThlY2U1NjJlYmZmYzE1ZDM4ODA4Y2Q2Yjk4ZmMzZDk3ZWEiLCJodG1sIjoiaHR0cHM6Ly9naXQubm54LmNvbS88VEVTVF9GT1JHRUpPX1JFUE9TSVRPUlk+L3NyYy9icmFuY2gvbWFpbi90ZXN0LnR4dCJ9fV0sImNvbW1pdCI6eyJ1cmwiOiI8VEVTVF9GT1JHRUpPX0VORFBPSU5UPi9yZXBvcy88VEVTVF9GT1JHRUpPX1JFUE9TSVRPUlk+L2dpdC9jb21taXRzLzdjMDY1NWM3Zjc2NmU4Y2QxNjM2YmUzZDBiZjQ4ZDY3ZmU5YjcxZWMiLCJzaGEiOiI3YzA2NTVjN2Y3NjZlOGNkMTYzNmJlM2QwYmY0OGQ2N2ZlOWI3MWVjIiwiY3JlYXRlZCI6IjAwMDEtMDEtMDFUMDA6MDA6MDBaIiwiaHRtbF91cmwiOiJodHRwczovL2dpdC5ubnguY29tLzxURVNUX0ZPUkdFSk9fUkVQT1NJVE9SWT4vY29tbWl0LzdjMDY1NWM3Zjc2NmU4Y2QxNjM2YmUzZDBiZjQ4ZDY3ZmU5YjcxZWMiLCJhdXRob3IiOnsibmFtZSI6IkplYW4gVHJhdWxsw6kiLCJlbWFpbCI6ImoudHJhdWxsZUBubnguY29tIiwiZGF0ZSI6IjIwMjYtMDctMTBUMTU6MzA6MjZaIn0sImNvbW1pdHRlciI6eyJuYW1lIjoiSmVhbiBUcmF1bGzDqSIsImVtYWlsIjoiai50cmF1bGxlQG5ueC5jb20iLCJkYXRlIjoiMjAyNi0wNy0xMFQxNTozMDoyNloifSwicGFyZW50cyI6W3sidXJsIjoiPFRFU1RfRk9SR0VKT19FTkRQT0lOVD4vcmVwb3MvPFRFU1RfRk9SR0VKT19SRVBPU0lUT1JZPi9naXQvY29tbWl0cy9jYzIxMTQ4NWQ0YTYxMzUwYmYyNDgzM2E0NmFmZWZlMzJhZjZmZDAyIiwic2hhIjoiY2MyMTE0ODVkNGE2MTM1MGJmMjQ4MzNhNDZhZmVmZTMyYWY2ZmQwMiIsImNyZWF0ZWQiOiIwMDAxLTAxLTAxVDAwOjAwOjAwWiJ9XSwibWVzc2FnZSI6IkNyZWF0aW5nIHRlc3QudHh0IGZpbGVcbiIsInRyZWUiOnsidXJsIjoiPFRFU1RfRk9SR0VKT19FTkRQT0lOVD4vcmVwb3MvPFRFU1RfRk9SR0VKT19SRVBPU0lUT1JZPi9naXQvdHJlZXMvY2MwMTJiNGNiOGU2YjAwODc1NjU3ZGZkMWZlZmU1ZjJjZDhlNWEwMSIsInNoYSI6ImNjMDEyYjRjYjhlNmIwMDg3NTY1N2RmZDFmZWZlNWYyY2Q4ZTVhMDEiLCJjcmVhdGVkIjoiMDAwMS0wMS0wMVQwMDowMDowMFoifX0sInZlcmlmaWNhdGlvbiI6eyJ2ZXJpZmllZCI6ZmFsc2UsInJlYXNvbiI6ImdwZy5lcnJvci5ub3Rfc2lnbmVkX2NvbW1pdCIsInNpZ25hdHVyZSI6IiIsInNpZ25lciI6bnVsbCwicGF5bG9hZCI6IiJ9fQo=
+ recorded_at: Fri, 10 Jul 2026 15:30:27 GMT
+recorded_with: VCR 6.4.0
diff --git a/test/cassettes/GitRepositoryTest_test_file_creation_on_github.yml b/test/cassettes/GitRepositoryTest_test_file_creation_on_github.yml
index d6f1290081..140bef61c7 100644
--- a/test/cassettes/GitRepositoryTest_test_file_creation_on_github.yml
+++ b/test/cassettes/GitRepositoryTest_test_file_creation_on_github.yml
@@ -2,7 +2,7 @@
http_interactions:
- request:
method: get
- uri: https://api.github.com/repos/
+ uri: https://api.github.com/repos//branches/main
body:
encoding: US-ASCII
string: ''
@@ -10,11 +10,11 @@ http_interactions:
Accept:
- application/vnd.github.v3+json
User-Agent:
- - Octokit Ruby Gem 4.22.0
+ - Octokit Ruby Gem 10.0.0
Content-Type:
- application/json
Authorization:
- - token
+ - ""
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
@@ -22,46 +22,29 @@ http_interactions:
code: 200
message: OK
headers:
- Server:
- - GitHub.com
Date:
- - Mon, 21 Feb 2022 17:08:15 GMT
+ - Fri, 10 Jul 2026 16:09:43 GMT
Content-Type:
- application/json; charset=utf-8
- Transfer-Encoding:
- - chunked
Cache-Control:
- private, max-age=60, s-maxage=60
Vary:
- - Accept, Authorization, Cookie, X-GitHub-OTP
- - Accept-Encoding, Accept, X-Requested-With
+ - Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
Etag:
- - W/"91265fec5343fc373cda2b6483110973098d8cf2b2779497d2b8c06bd665e942"
- Last-Modified:
- - Mon, 10 Jan 2022 16:10:05 GMT
- X-Oauth-Scopes:
- - repo
- X-Accepted-Oauth-Scopes:
- - repo
+ - W/"d2243e2602ab6cbe05e316cad698960fbafdcebfb0a4624bdf4f130d8ba4b2db"
Github-Authentication-Token-Expiration:
- - 2022-03-02 10:41:25 UTC
+ - 2026-08-09 15:59:30 UTC
X-Github-Media-Type:
- github.v3; format=json
- X-Ratelimit-Limit:
- - '5000'
- X-Ratelimit-Remaining:
- - '4958'
- X-Ratelimit-Reset:
- - '1645466064'
- X-Ratelimit-Used:
- - '42'
- X-Ratelimit-Resource:
- - core
+ X-Accepted-Github-Permissions:
+ - contents=read
+ X-Github-Api-Version-Selected:
+ - '2022-11-28'
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
- X-GitHub-Request-Id, Deprecation, Sunset
+ X-GitHub-Request-Id, Deprecation, Sunset, Warning
Access-Control-Allow-Origin:
- "*"
Strict-Transport-Security:
@@ -76,102 +59,30 @@ http_interactions:
- origin-when-cross-origin, strict-origin-when-cross-origin
Content-Security-Policy:
- default-src 'none'
- X-Github-Request-Id:
- - D21D:0177:20DDCF:224696:6213C6FE
- body:
- encoding: ASCII-8BIT
- string: '{"id":420334271,"node_id":"R_kgDOGQ3Kvw","name":"bordeauxmontaigne-test","full_name":"","private":false,"owner":{"login":"noesya","id":87527458,"node_id":"MDEyOk9yZ2FuaXphdGlvbjg3NTI3NDU4","avatar_url":"https://avatars.githubusercontent.com/u/87527458?v=4","gravatar_id":"","url":"https://api.github.com/users/noesya","html_url":"https://github.com/noesya","followers_url":"https://api.github.com/users/noesya/followers","following_url":"https://api.github.com/users/noesya/following{/other_user}","gists_url":"https://api.github.com/users/noesya/gists{/gist_id}","starred_url":"https://api.github.com/users/noesya/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/noesya/subscriptions","organizations_url":"https://api.github.com/users/noesya/orgs","repos_url":"https://api.github.com/users/noesya/repos","events_url":"https://api.github.com/users/noesya/events{/privacy}","received_events_url":"https://api.github.com/users/noesya/received_events","type":"Organization","site_admin":false},"html_url":"https://github.com/","description":null,"fork":false,"url":"https://api.github.com/repos/","forks_url":"https://api.github.com/repos//forks","keys_url":"https://api.github.com/repos//keys{/key_id}","collaborators_url":"https://api.github.com/repos//collaborators{/collaborator}","teams_url":"https://api.github.com/repos//teams","hooks_url":"https://api.github.com/repos//hooks","issue_events_url":"https://api.github.com/repos//issues/events{/number}","events_url":"https://api.github.com/repos//events","assignees_url":"https://api.github.com/repos//assignees{/user}","branches_url":"https://api.github.com/repos//branches{/branch}","tags_url":"https://api.github.com/repos//tags","blobs_url":"https://api.github.com/repos//git/blobs{/sha}","git_tags_url":"https://api.github.com/repos//git/tags{/sha}","git_refs_url":"https://api.github.com/repos//git/refs{/sha}","trees_url":"https://api.github.com/repos//git/trees{/sha}","statuses_url":"https://api.github.com/repos//statuses/{sha}","languages_url":"https://api.github.com/repos//languages","stargazers_url":"https://api.github.com/repos//stargazers","contributors_url":"https://api.github.com/repos//contributors","subscribers_url":"https://api.github.com/repos//subscribers","subscription_url":"https://api.github.com/repos//subscription","commits_url":"https://api.github.com/repos//commits{/sha}","git_commits_url":"https://api.github.com/repos//git/commits{/sha}","comments_url":"https://api.github.com/repos//comments{/number}","issue_comment_url":"https://api.github.com/repos//issues/comments{/number}","contents_url":"https://api.github.com/repos//contents/{+path}","compare_url":"https://api.github.com/repos//compare/{base}...{head}","merges_url":"https://api.github.com/repos//merges","archive_url":"https://api.github.com/repos//{archive_format}{/ref}","downloads_url":"https://api.github.com/repos//downloads","issues_url":"https://api.github.com/repos//issues{/number}","pulls_url":"https://api.github.com/repos//pulls{/number}","milestones_url":"https://api.github.com/repos//milestones{/number}","notifications_url":"https://api.github.com/repos//notifications{?since,all,participating}","labels_url":"https://api.github.com/repos//labels{/name}","releases_url":"https://api.github.com/repos//releases{/id}","deployments_url":"https://api.github.com/repos//deployments","created_at":"2021-10-23T06:34:47Z","updated_at":"2022-01-10T16:10:05Z","pushed_at":"2022-02-21T17:08:08Z","git_url":"git://github.com/.git","ssh_url":"git@github.com:.git","clone_url":"https://github.com/.git","svn_url":"https://github.com/","homepage":null,"size":2648,"stargazers_count":0,"watchers_count":0,"language":"HTML","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":0,"license":null,"allow_forking":true,"is_template":false,"topics":[],"visibility":"public","forks":0,"open_issues":0,"watchers":0,"default_branch":"main","permissions":{"admin":true,"maintain":true,"push":true,"triage":true,"pull":true},"temp_clone_token":"","allow_squash_merge":true,"allow_merge_commit":true,"allow_rebase_merge":true,"allow_auto_merge":false,"delete_branch_on_merge":false,"allow_update_branch":false,"organization":{"login":"noesya","id":87527458,"node_id":"MDEyOk9yZ2FuaXphdGlvbjg3NTI3NDU4","avatar_url":"https://avatars.githubusercontent.com/u/87527458?v=4","gravatar_id":"","url":"https://api.github.com/users/noesya","html_url":"https://github.com/noesya","followers_url":"https://api.github.com/users/noesya/followers","following_url":"https://api.github.com/users/noesya/following{/other_user}","gists_url":"https://api.github.com/users/noesya/gists{/gist_id}","starred_url":"https://api.github.com/users/noesya/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/noesya/subscriptions","organizations_url":"https://api.github.com/users/noesya/orgs","repos_url":"https://api.github.com/users/noesya/repos","events_url":"https://api.github.com/users/noesya/events{/privacy}","received_events_url":"https://api.github.com/users/noesya/received_events","type":"Organization","site_admin":false},"network_count":0,"subscribers_count":5}'
- recorded_at: Mon, 21 Feb 2022 17:08:15 GMT
-- request:
- method: get
- uri: https://api.github.com/repos//branches/main
- body:
- encoding: US-ASCII
- string: ''
- headers:
- Accept:
- - application/vnd.github.v3+json
- User-Agent:
- - Octokit Ruby Gem 4.22.0
- Content-Type:
- - application/json
- Authorization:
- - token
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- response:
- status:
- code: 200
- message: OK
- headers:
- Server:
- - GitHub.com
- Date:
- - Mon, 21 Feb 2022 17:08:15 GMT
- Content-Type:
- - application/json; charset=utf-8
Transfer-Encoding:
- chunked
- Cache-Control:
- - private, max-age=60, s-maxage=60
- Vary:
- - Accept, Authorization, Cookie, X-GitHub-OTP
- - Accept-Encoding, Accept, X-Requested-With
- Etag:
- - W/"75f5441ef8e10f2b0bf0e8e9215e38fb35e4bb42cb23172bf8b99cee3b29b5ec"
- X-Oauth-Scopes:
- - repo
- X-Accepted-Oauth-Scopes:
- - ''
- Github-Authentication-Token-Expiration:
- - 2022-03-02 10:41:25 UTC
- X-Github-Media-Type:
- - github.v3; format=json
+ Server:
+ - github.com
X-Ratelimit-Limit:
- '5000'
X-Ratelimit-Remaining:
- - '4957'
+ - '4999'
X-Ratelimit-Reset:
- - '1645466064'
+ - '1783703383'
X-Ratelimit-Used:
- - '43'
+ - '1'
X-Ratelimit-Resource:
- core
- Access-Control-Expose-Headers:
- - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
- X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
- X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
- X-GitHub-Request-Id, Deprecation, Sunset
- Access-Control-Allow-Origin:
- - "*"
- Strict-Transport-Security:
- - max-age=31536000; includeSubdomains; preload
- X-Frame-Options:
- - deny
- X-Content-Type-Options:
- - nosniff
- X-Xss-Protection:
- - '0'
- Referrer-Policy:
- - origin-when-cross-origin, strict-origin-when-cross-origin
- Content-Security-Policy:
- - default-src 'none'
X-Github-Request-Id:
- - D220:0181:756E59:778325:6213C6FF
+ - A04E:26DB78:1582D70:145F50F:6A511947
body:
encoding: ASCII-8BIT
- string: '{"name":"main","commit":{"sha":"d9997b554e2e49e04d9d253f1ad133e005ab900e","node_id":"C_kwDOGQ3Kv9oAKGQ5OTk3YjU1NGUyZTQ5ZTA0ZDlkMjUzZjFhZDEzM2UwMDVhYjkwMGU","commit":{"author":{"name":"Arnaud
- Levy","email":"arnaud.levy@noesya.coop","date":"2022-02-21T17:08:08Z"},"committer":{"name":"GitHub","email":"noreply@github.com","date":"2022-02-21T17:08:08Z"},"message":"Delete
- test.txt","tree":{"sha":"015cfc1dd98d863c0857a2682702effa08ac78e1","url":"https://api.github.com/repos//git/trees/015cfc1dd98d863c0857a2682702effa08ac78e1"},"url":"https://api.github.com/repos//git/commits/d9997b554e2e49e04d9d253f1ad133e005ab900e","comment_count":0,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN
- PGP SIGNATURE-----\n\nwsBcBAABCAAQBQJiE8b4CRBK7hj4Ov3rIwAA0NAIAAxC8T1t4949QAZL/cyc94vp\nzfHDiVdrWz8tretP9diglZ5eKq1HtVRAC5NWZkL3FVF7jEk7f30DYbaPyOhbstmJ\nXkAZEZBmir8zoGKgiROHKyMWjKbXap9AsVWIOO4XmGgS+p2Xx548qT2lQoSuuSSf\nnTP9hCeWW5CzIhcO0L2qyqOff03cAbupybzCCtcTShCA0hQE7SUceGvLf1ldo2sg\nW3aadPgMbxxjp6q9Avgs/yt8ptXhYiHBjQbAPKbsa7oT35GLZfpAmRzC4KyvL40b\nXmxaZHaIpB38MgPMajgvVZ6e3ZYmumQHtGb+9xpqdVcpRXLaxKGr6yQY28WPXSM=\n=0PuI\n-----END
- PGP SIGNATURE-----\n","payload":"tree 015cfc1dd98d863c0857a2682702effa08ac78e1\nparent
- 58bd93682baaddebc89a135b0be5b06a824a4c3d\nauthor Arnaud Levy
- 1645463288 +0100\ncommitter GitHub 1645463288 +0100\n\nDelete
- test.txt"}},"url":"https://api.github.com/repos//commits/d9997b554e2e49e04d9d253f1ad133e005ab900e","html_url":"https://github.com//commit/d9997b554e2e49e04d9d253f1ad133e005ab900e","comments_url":"https://api.github.com/repos//commits/d9997b554e2e49e04d9d253f1ad133e005ab900e/comments","author":{"login":"arnaudlevy","id":2088968,"node_id":"MDQ6VXNlcjIwODg5Njg=","avatar_url":"https://avatars.githubusercontent.com/u/2088968?v=4","gravatar_id":"","url":"https://api.github.com/users/arnaudlevy","html_url":"https://github.com/arnaudlevy","followers_url":"https://api.github.com/users/arnaudlevy/followers","following_url":"https://api.github.com/users/arnaudlevy/following{/other_user}","gists_url":"https://api.github.com/users/arnaudlevy/gists{/gist_id}","starred_url":"https://api.github.com/users/arnaudlevy/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/arnaudlevy/subscriptions","organizations_url":"https://api.github.com/users/arnaudlevy/orgs","repos_url":"https://api.github.com/users/arnaudlevy/repos","events_url":"https://api.github.com/users/arnaudlevy/events{/privacy}","received_events_url":"https://api.github.com/users/arnaudlevy/received_events","type":"User","site_admin":false},"committer":{"login":"web-flow","id":19864447,"node_id":"MDQ6VXNlcjE5ODY0NDQ3","avatar_url":"https://avatars.githubusercontent.com/u/19864447?v=4","gravatar_id":"","url":"https://api.github.com/users/web-flow","html_url":"https://github.com/web-flow","followers_url":"https://api.github.com/users/web-flow/followers","following_url":"https://api.github.com/users/web-flow/following{/other_user}","gists_url":"https://api.github.com/users/web-flow/gists{/gist_id}","starred_url":"https://api.github.com/users/web-flow/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/web-flow/subscriptions","organizations_url":"https://api.github.com/users/web-flow/orgs","repos_url":"https://api.github.com/users/web-flow/repos","events_url":"https://api.github.com/users/web-flow/events{/privacy}","received_events_url":"https://api.github.com/users/web-flow/received_events","type":"User","site_admin":false},"parents":[{"sha":"58bd93682baaddebc89a135b0be5b06a824a4c3d","url":"https://api.github.com/repos//commits/58bd93682baaddebc89a135b0be5b06a824a4c3d","html_url":"https://github.com//commit/58bd93682baaddebc89a135b0be5b06a824a4c3d"}]},"_links":{"self":"https://api.github.com/repos//branches/main","html":"https://github.com//tree/main"},"protected":false,"protection":{"enabled":false,"required_status_checks":{"enforcement_level":"off","contexts":[],"checks":[]}},"protection_url":"https://api.github.com/repos//branches/main/protection"}'
- recorded_at: Mon, 21 Feb 2022 17:08:15 GMT
+ string: !binary |-
+ eyJuYW1lIjoibWFpbiIsImNvbW1pdCI6eyJzaGEiOiJjNzllZTRiZGVkNjM1MDQ0YmNkYTEwNDFkMzExMDNmMjQwNGY0OGQzIiwibm9kZV9pZCI6IkNfa3dET1RVbWM5OW9BS0dNM09XVmxOR0prWldRMk16VXdORFJpWTJSaE1UQTBNV1F6TVRFd00yWXlOREEwWmpRNFpETSIsImNvbW1pdCI6eyJhdXRob3IiOnsibmFtZSI6IkplYW4gVHJhdWxsw6kiLCJlbWFpbCI6IjYxMzYxNStqdHJhdWxsZUB1c2Vycy5ub3JlcGx5LmdpdGh1Yi5jb20iLCJkYXRlIjoiMjAyNi0wNy0xMFQxNTo1NzozN1oifSwiY29tbWl0dGVyIjp7Im5hbWUiOiJHaXRIdWIiLCJlbWFpbCI6Im5vcmVwbHlAZ2l0aHViLmNvbSIsImRhdGUiOiIyMDI2LTA3LTEwVDE1OjU3OjM3WiJ9LCJtZXNzYWdlIjoiQWRkIGluaXRpYWwgUkVBRE1FLm1kIGZpbGUiLCJ0cmVlIjp7InNoYSI6IjU2OWIyYzZjNzZmNGFjMDQyNzAwZGYwZTg0ZDkyYjU4NzRkNDAwMGYiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zLzxURVNUX0dJVEhVQl9SRVBPU0lUT1JZPi9naXQvdHJlZXMvNTY5YjJjNmM3NmY0YWMwNDI3MDBkZjBlODRkOTJiNTg3NGQ0MDAwZiJ9LCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zLzxURVNUX0dJVEhVQl9SRVBPU0lUT1JZPi9naXQvY29tbWl0cy9jNzllZTRiZGVkNjM1MDQ0YmNkYTEwNDFkMzExMDNmMjQwNGY0OGQzIiwiY29tbWVudF9jb3VudCI6MCwidmVyaWZpY2F0aW9uIjp7InZlcmlmaWVkIjp0cnVlLCJyZWFzb24iOiJ2YWxpZCIsInNpZ25hdHVyZSI6Ii0tLS0tQkVHSU4gUEdQIFNJR05BVFVSRS0tLS0tXG5cbndzRmNCQUFCQ0FBUUJRSnFVUlp4Q1JDMWFRN3V1NVVobEFBQS8yWVFBQTd6enlMeWpLTzFZMWJxUVNBWUE5dEhcbm9Fa0RaUHNBUWxNRnFMRzZXVjRTelhreTRRUGdDK2pXRDFPdnQvYmhyWWFGNTBwamZ4bnpZQXY3ZnM3NUtJZjVcbkIxbU5FcElGVklpZFoxU0ZHbzhEa2Q3OEVPc2o3MSt6eE5wa2E3bHZrVW00TjY5OVVHbGthbnU3eGhWTmFJbEtcbkFVTEJIYURjUllvZDRRNnVBSVNPUmg0ZUpRS3l1TzZJVEZXeWlPNjhneSsvWkJCazI1VDFoc1dmNzNUNlR1SVVcbmk2TlQvNXJ3eTYzdnJ0OUxCUmZBS0hRTDE3bk5ScHJDdWlRNDZUTHhxMVl2VkdMdkNiT29kRWtnZEJrWEY3M2hcbkFTd20rcXNScXM0UGVYZkkybUx6S0d6Q2xoNFVPOXRMeEhBT1ZxZWRaN3RyMkxhTTFUeFdLUlZvOThCK0tRUHdcbm8yRkV4YVM5M0FISDgvMFl2Y2cwVmluVi9TNDNhdmRJK0wzWDkvOUNwOElOdVNkRE1CRDV1bm1mb2xvRG16bGZcblZMakFuay8rYUdUR0R0bGNmVzc2VG85MTNJcDVJNG1CWE1MZE4rYmRldTB1enllMkNjeUM4M3pBRzg2UU5QV2VcbjN3YXoxZGcxeS9UWVBSQkM2eUZsQjdIYXFPT2hZMHUxWDNSTks5TWt4aHpCTjB3OG1teTFoa0xXV0J1UE1BYVNcbkh4UXpSYlZDK0g3NldVcjl4M0lVUDdXSWN5cEs4cjZFcHhmTlZGZ2tIK2NOTUpSUm5XWjFreENKTUZPZmhwMVhcbllQZk5tSGNSQVN6b3E2QkkrTVhaem5RSFNmcEFidkp6Z2ZMSnYyZzhiVXdlcjQwaTRaM2dvdW5tVlp0NWFxaWlcblpwOTQ1MDc2SXZTSk5PeEFLU0FiXG49c1EzTVxuLS0tLS1FTkQgUEdQIFNJR05BVFVSRS0tLS0tXG4iLCJwYXlsb2FkIjoidHJlZSA1NjliMmM2Yzc2ZjRhYzA0MjcwMGRmMGU4NGQ5MmI1ODc0ZDQwMDBmXG5hdXRob3IgSmVhbiBUcmF1bGzDqSA8NjEzNjE1K2p0cmF1bGxlQHVzZXJzLm5vcmVwbHkuZ2l0aHViLmNvbT4gMTc4MzY5OTA1NyArMDIwMFxuY29tbWl0dGVyIEdpdEh1YiA8bm9yZXBseUBnaXRodWIuY29tPiAxNzgzNjk5MDU3ICswMjAwXG5cbkFkZCBpbml0aWFsIFJFQURNRS5tZCBmaWxlIiwidmVyaWZpZWRfYXQiOiIyMDI2LTA3LTEwVDE1OjU3OjM4WiJ9fSwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy88VEVTVF9HSVRIVUJfUkVQT1NJVE9SWT4vY29tbWl0cy9jNzllZTRiZGVkNjM1MDQ0YmNkYTEwNDFkMzExMDNmMjQwNGY0OGQzIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vPFRFU1RfR0lUSFVCX1JFUE9TSVRPUlk+L2NvbW1pdC9jNzllZTRiZGVkNjM1MDQ0YmNkYTEwNDFkMzExMDNmMjQwNGY0OGQzIiwiY29tbWVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy88VEVTVF9HSVRIVUJfUkVQT1NJVE9SWT4vY29tbWl0cy9jNzllZTRiZGVkNjM1MDQ0YmNkYTEwNDFkMzExMDNmMjQwNGY0OGQzL2NvbW1lbnRzIiwiYXV0aG9yIjp7ImxvZ2luIjoianRyYXVsbGUiLCJpZCI6NjEzNjE1LCJub2RlX2lkIjoiTURRNlZYTmxjall4TXpZeE5RPT0iLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzYxMzYxNT92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvanRyYXVsbGUiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9qdHJhdWxsZSIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2p0cmF1bGxlL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2p0cmF1bGxlL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2p0cmF1bGxlL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9qdHJhdWxsZS9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2p0cmF1bGxlL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvanRyYXVsbGUvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvanRyYXVsbGUvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9qdHJhdWxsZS9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvanRyYXVsbGUvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwiY29tbWl0dGVyIjp7ImxvZ2luIjoid2ViLWZsb3ciLCJpZCI6MTk4NjQ0NDcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTVPRFkwTkRRMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTk4NjQ0NDc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3dlYi1mbG93IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vd2ViLWZsb3ciLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy93ZWItZmxvdy9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy93ZWItZmxvdy9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy93ZWItZmxvdy9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvd2ViLWZsb3cvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy93ZWItZmxvdy9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3dlYi1mbG93L29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3dlYi1mbG93L3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvd2ViLWZsb3cvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3dlYi1mbG93L3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sInBhcmVudHMiOltdfSwiX2xpbmtzIjp7InNlbGYiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zLzxURVNUX0dJVEhVQl9SRVBPU0lUT1JZPi9icmFuY2hlcy9tYWluIiwiaHRtbCI6Imh0dHBzOi8vZ2l0aHViLmNvbS88VEVTVF9HSVRIVUJfUkVQT1NJVE9SWT4vdHJlZS9tYWluIn0sInByb3RlY3RlZCI6ZmFsc2UsInByb3RlY3Rpb24iOnsiZW5hYmxlZCI6ZmFsc2UsInJlcXVpcmVkX3N0YXR1c19jaGVja3MiOnsiZW5mb3JjZW1lbnRfbGV2ZWwiOiJvZmYiLCJjb250ZXh0cyI6W10sImNoZWNrcyI6W119fSwicHJvdGVjdGlvbl91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zLzxURVNUX0dJVEhVQl9SRVBPU0lUT1JZPi9icmFuY2hlcy9tYWluL3Byb3RlY3Rpb24ifQ==
+ recorded_at: Fri, 10 Jul 2026 16:09:43 GMT
- request:
method: get
- uri: https://api.github.com/repos//git/trees/d9997b554e2e49e04d9d253f1ad133e005ab900e?recursive=true
+ uri: https://api.github.com/repos//git/trees/c79ee4bded635044bcda1041d31103f2404f48d3?recursive=true
body:
encoding: US-ASCII
string: ''
@@ -179,11 +90,11 @@ http_interactions:
Accept:
- application/vnd.github.v3+json
User-Agent:
- - Octokit Ruby Gem 4.22.0
+ - Octokit Ruby Gem 10.0.0
Content-Type:
- application/json
Authorization:
- - token
+ - ""
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
@@ -191,46 +102,31 @@ http_interactions:
code: 200
message: OK
headers:
- Server:
- - GitHub.com
Date:
- - Mon, 21 Feb 2022 17:08:15 GMT
+ - Fri, 10 Jul 2026 16:09:44 GMT
Content-Type:
- application/json; charset=utf-8
- Transfer-Encoding:
- - chunked
Cache-Control:
- private, max-age=86400, s-maxage=86400
Vary:
- - Accept, Authorization, Cookie, X-GitHub-OTP
- - Accept-Encoding, Accept, X-Requested-With
+ - Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
Etag:
- - W/"6b8ff029143aa5cd678606cc8f504e557c31369ea48eda2a13a48bcdbaf39561"
+ - W/"a38e99cb4a74f78b600b9a4bf315e2bb66a962ab45918654f68f1e63eaa4d982"
Last-Modified:
- - Mon, 10 Jan 2022 16:10:05 GMT
- X-Oauth-Scopes:
- - repo
- X-Accepted-Oauth-Scopes:
- - ''
+ - Fri, 10 Jul 2026 15:58:40 GMT
Github-Authentication-Token-Expiration:
- - 2022-03-02 10:41:25 UTC
+ - 2026-08-09 15:59:30 UTC
X-Github-Media-Type:
- github.v3; format=json
- X-Ratelimit-Limit:
- - '5000'
- X-Ratelimit-Remaining:
- - '4955'
- X-Ratelimit-Reset:
- - '1645466064'
- X-Ratelimit-Used:
- - '45'
- X-Ratelimit-Resource:
- - core
+ X-Accepted-Github-Permissions:
+ - contents=read
+ X-Github-Api-Version-Selected:
+ - '2022-11-28'
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,
X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,
- X-GitHub-Request-Id, Deprecation, Sunset
+ X-GitHub-Request-Id, Deprecation, Sunset, Warning
Access-Control-Allow-Origin:
- "*"
Strict-Transport-Security:
@@ -245,27 +141,41 @@ http_interactions:
- origin-when-cross-origin, strict-origin-when-cross-origin
Content-Security-Policy:
- default-src 'none'
+ Transfer-Encoding:
+ - chunked
+ Server:
+ - github.com
+ X-Ratelimit-Limit:
+ - '5000'
+ X-Ratelimit-Remaining:
+ - '4998'
+ X-Ratelimit-Reset:
+ - '1783703383'
+ X-Ratelimit-Used:
+ - '2'
+ X-Ratelimit-Resource:
+ - core
X-Github-Request-Id:
- - D222:556B:715381:7354AA:6213C6FF
+ - A05C:3726CB:1581DD5:145D95E:6A511947
body:
encoding: ASCII-8BIT
- string: '{"sha":"d9997b554e2e49e04d9d253f1ad133e005ab900e","url":"https://api.github.com/repos//git/trees/d9997b554e2e49e04d9d253f1ad133e005ab900e","tree":[{"path":"_articles","mode":"040000","type":"tree","sha":"bfe2ee494f61c4e6ab421a188f0b6326c3503452","url":"https://api.github.com/repos//git/trees/bfe2ee494f61c4e6ab421a188f0b6326c3503452"},{"path":"_articles/852af671-5552-447a-bd3e-b4fe9c379dee.html","mode":"100644","type":"blob","sha":"f92ccc5866074f1162b1fd8ec78b845428976fbe","size":73839,"url":"https://api.github.com/repos//git/blobs/f92ccc5866074f1162b1fd8ec78b845428976fbe"},{"path":"_authors","mode":"040000","type":"tree","sha":"571f08a27b5d85e517246f5e0ab7b2e8f3eeb1bb","url":"https://api.github.com/repos//git/trees/571f08a27b5d85e517246f5e0ab7b2e8f3eeb1bb"},{"path":"_authors/29958948-4f82-431e-96e1-4adb8fed1ae3.md","mode":"100644","type":"blob","sha":"e3726a951f0aa4d6543fac8800fea1766e4d8e94","size":338,"url":"https://api.github.com/repos//git/blobs/e3726a951f0aa4d6543fac8800fea1766e4d8e94"},{"path":"_authors/boissinot-pierre-andre.html","mode":"100644","type":"blob","sha":"cbf40010516328506d69e289cb2ef038c425db6d","size":120,"url":"https://api.github.com/repos//git/blobs/cbf40010516328506d69e289cb2ef038c425db6d"},{"path":"_authors/hbeneyrol.html","mode":"100644","type":"blob","sha":"e26d61919fcde8ddbb75572ee06a9018c325683b","size":159,"url":"https://api.github.com/repos//git/blobs/e26d61919fcde8ddbb75572ee06a9018c325683b"},{"path":"_authors/levy-arnaud.html","mode":"100644","type":"blob","sha":"f5eb0a9188719535f5b7ce48e71ad79184ed6a67","size":157,"url":"https://api.github.com/repos//git/blobs/f5eb0a9188719535f5b7ce48e71ad79184ed6a67"},{"path":"_categories","mode":"040000","type":"tree","sha":"15be0c30ae0985a88de3916034c3631928d5c4c9","url":"https://api.github.com/repos//git/trees/15be0c30ae0985a88de3916034c3631928d5c4c9"},{"path":"_categories/categorie-1.html","mode":"100644","type":"blob","sha":"9b704bc4a176ce78dee49b6e46a66e116d813c93","size":140,"url":"https://api.github.com/repos//git/blobs/9b704bc4a176ce78dee49b6e46a66e116d813c93"},{"path":"_categories/offre-de-formation.html","mode":"100644","type":"blob","sha":"c2635e0e7ecf4310eb7eb1a1f6895e7709890583","size":144,"url":"https://api.github.com/repos//git/blobs/c2635e0e7ecf4310eb7eb1a1f6895e7709890583"},{"path":"_categories/vie-scolaire.html","mode":"100644","type":"blob","sha":"9471989b657969ef21ea4a8692d9a6ee5378ff4c","size":132,"url":"https://api.github.com/repos//git/blobs/9471989b657969ef21ea4a8692d9a6ee5378ff4c"},{"path":"_data","mode":"040000","type":"tree","sha":"72af10a2892ecc71d685bbe29ff3dd77c99d2ccd","url":"https://api.github.com/repos//git/trees/72af10a2892ecc71d685bbe29ff3dd77c99d2ccd"},{"path":"_data/authors","mode":"040000","type":"tree","sha":"69b8723831f7aa72e7e88d72351596ef7863cf03","url":"https://api.github.com/repos//git/trees/69b8723831f7aa72e7e88d72351596ef7863cf03"},{"path":"_data/authors/pierre-andre-boissinot.yml","mode":"100644","type":"blob","sha":"54ad882ab8e69604e3292c65c2609936540d52ae","size":123,"url":"https://api.github.com/repos//git/blobs/54ad882ab8e69604e3292c65c2609936540d52ae"},{"path":"_data/categories","mode":"040000","type":"tree","sha":"2c270e42f0f30ea52da25e28dc3e50768dcda142","url":"https://api.github.com/repos//git/trees/2c270e42f0f30ea52da25e28dc3e50768dcda142"},{"path":"_data/categories/bachelor-universitaire-de-technologie-metiers-du-multimedia-et-de-l-internet.yml","mode":"100644","type":"blob","sha":"b40af2feb9fd5ef4b383ae98d3b7efecc846659e","size":298,"url":"https://api.github.com/repos//git/blobs/b40af2feb9fd5ef4b383ae98d3b7efecc846659e"},{"path":"_data/categories/cat-test.yml","mode":"100644","type":"blob","sha":"58b5a2fe3dfce22ff16ff40813e92cd2c75a7db5","size":120,"url":"https://api.github.com/repos//git/blobs/58b5a2fe3dfce22ff16ff40813e92cd2c75a7db5"},{"path":"_data/media","mode":"040000","type":"tree","sha":"71a334c944b8c17cc99acfd111f2f49d9475f5ee","url":"https://api.github.com/repos//git/trees/71a334c944b8c17cc99acfd111f2f49d9475f5ee"},{"path":"_data/media/58","mode":"040000","type":"tree","sha":"6fe26cf54fff57aa19eb1d0c788c358f6941902b","url":"https://api.github.com/repos//git/trees/6fe26cf54fff57aa19eb1d0c788c358f6941902b"},{"path":"_data/media/58/58e733aa-0586-4b4a-922d-bb807a4a5f63.yml","mode":"100644","type":"blob","sha":"2c1e21aa789833b690ac170edb6dcd6341b3ed02","size":335,"url":"https://api.github.com/repos//git/blobs/2c1e21aa789833b690ac170edb6dcd6341b3ed02"},{"path":"_data/media/7e","mode":"040000","type":"tree","sha":"e5b65a678ae3d05e9ad9acd68bddc1a5aa75445e","url":"https://api.github.com/repos//git/trees/e5b65a678ae3d05e9ad9acd68bddc1a5aa75445e"},{"path":"_data/media/7e/7e60978f-ab38-4198-90c5-7d9f3e3adafa.yml","mode":"100644","type":"blob","sha":"438ff763a68b9ec3661a4736b5524eaad1f796ff","size":327,"url":"https://api.github.com/repos//git/blobs/438ff763a68b9ec3661a4736b5524eaad1f796ff"},{"path":"_data/media/90","mode":"040000","type":"tree","sha":"70743d6065c3fc8ab6ba65412f3db1df7485e287","url":"https://api.github.com/repos//git/trees/70743d6065c3fc8ab6ba65412f3db1df7485e287"},{"path":"_data/media/90/90135e24-806e-414e-bdbe-342c582dbf6b.yml","mode":"100644","type":"blob","sha":"624d30827892ecb94cb868ad2afc70b81ddd9448","size":309,"url":"https://api.github.com/repos//git/blobs/624d30827892ecb94cb868ad2afc70b81ddd9448"},{"path":"_data/media/9f","mode":"040000","type":"tree","sha":"3d55fa12b5e3d49272257dbb797e617a4e9405f2","url":"https://api.github.com/repos//git/trees/3d55fa12b5e3d49272257dbb797e617a4e9405f2"},{"path":"_data/media/9f/9fa00165-fd5e-4bbb-839b-6a00f238f056.yml","mode":"100644","type":"blob","sha":"44c77ada99f0ca3fade0471935bdc727d8bed3c0","size":396,"url":"https://api.github.com/repos//git/blobs/44c77ada99f0ca3fade0471935bdc727d8bed3c0"},{"path":"_data/media/ba","mode":"040000","type":"tree","sha":"d351796b997aaf94bb48e576faa6163eb8288645","url":"https://api.github.com/repos//git/trees/d351796b997aaf94bb48e576faa6163eb8288645"},{"path":"_data/media/ba/ba8eec96-b8ee-4ac6-94eb-978578c5cc98.yml","mode":"100644","type":"blob","sha":"bb938e2f4d99af8b39ca2e8707d8d2abe141cee5","size":444,"url":"https://api.github.com/repos//git/blobs/bb938e2f4d99af8b39ca2e8707d8d2abe141cee5"},{"path":"_data/media/e1","mode":"040000","type":"tree","sha":"2885c551318e686458407536e35a7bf2543b4edd","url":"https://api.github.com/repos//git/trees/2885c551318e686458407536e35a7bf2543b4edd"},{"path":"_data/media/e1/e116a7f4-37d2-4c97-9403-b3e94661f168.yml","mode":"100644","type":"blob","sha":"b0ecca511e754585b32fb330fb67bcff4d69e4f4","size":460,"url":"https://api.github.com/repos//git/blobs/b0ecca511e754585b32fb330fb67bcff4d69e4f4"},{"path":"_data/menus","mode":"040000","type":"tree","sha":"4d981dbb2e9f147c6aa5f7fa844dfb6a6a69ed15","url":"https://api.github.com/repos//git/trees/4d981dbb2e9f147c6aa5f7fa844dfb6a6a69ed15"},{"path":"_data/menus/menu-principal.yml","mode":"100644","type":"blob","sha":"dcd024e994e796d888de9555fe53c3ada020b945","size":7,"url":"https://api.github.com/repos//git/blobs/dcd024e994e796d888de9555fe53c3ada020b945"},{"path":"_data/researchers","mode":"040000","type":"tree","sha":"afe069179a8049745eb6f62ff636a8cc14ae454c","url":"https://api.github.com/repos//git/trees/afe069179a8049745eb6f62ff636a8cc14ae454c"},{"path":"_data/researchers/pierre-andre-boissinot.yml","mode":"100644","type":"blob","sha":"c3ecde9cd39c878e621000a925069654b0236c4e","size":171,"url":"https://api.github.com/repos//git/blobs/c3ecde9cd39c878e621000a925069654b0236c4e"},{"path":"_data/school.yml","mode":"100644","type":"blob","sha":"085f654e88bb9f88fdfb33470a30d5f2de2b3dce","size":129,"url":"https://api.github.com/repos//git/blobs/085f654e88bb9f88fdfb33470a30d5f2de2b3dce"},{"path":"_data/teachers","mode":"040000","type":"tree","sha":"4755e1223a1ac017bdd15f1b7229e8c71b9e2333","url":"https://api.github.com/repos//git/trees/4755e1223a1ac017bdd15f1b7229e8c71b9e2333"},{"path":"_data/teachers/pierre-andre-boissinot.yml","mode":"100644","type":"blob","sha":"a67055dfce3bc360158b2def7fe08e266687e5d2","size":306,"url":"https://api.github.com/repos//git/blobs/a67055dfce3bc360158b2def7fe08e266687e5d2"},{"path":"_pages","mode":"040000","type":"tree","sha":"acb942dbe07503f91812583c5b25bc61e9213c60","url":"https://api.github.com/repos//git/trees/acb942dbe07503f91812583c5b25bc61e9213c60"},{"path":"_pages/apprentissage","mode":"040000","type":"tree","sha":"3a6c2c17dbd7f94dc08bb64176791172435b0c10","url":"https://api.github.com/repos//git/trees/3a6c2c17dbd7f94dc08bb64176791172435b0c10"},{"path":"_pages/apprentissage/index.html","mode":"100644","type":"blob","sha":"b398fe5c87a0c7fc42374102d55f215ae027dc9a","size":1583,"url":"https://api.github.com/repos//git/blobs/b398fe5c87a0c7fc42374102d55f215ae027dc9a"},{"path":"_pages/centre-de-ressources","mode":"040000","type":"tree","sha":"8338d92c2ea53a64fa8545fa8fac62a34a5b23cd","url":"https://api.github.com/repos//git/trees/8338d92c2ea53a64fa8545fa8fac62a34a5b23cd"},{"path":"_pages/centre-de-ressources/consulter-et-emprunter","mode":"040000","type":"tree","sha":"477241a33b0abfdc87c627f414b378aa216aa6ff","url":"https://api.github.com/repos//git/trees/477241a33b0abfdc87c627f414b378aa216aa6ff"},{"path":"_pages/centre-de-ressources/consulter-et-emprunter/index.html","mode":"100644","type":"blob","sha":"98b176953208b4be49427a2764575a978695e136","size":1796,"url":"https://api.github.com/repos//git/blobs/98b176953208b4be49427a2764575a978695e136"},{"path":"_pages/centre-de-ressources/index.html","mode":"100644","type":"blob","sha":"be9b12a99001aa66a5628fc29deace5094b30c05","size":957,"url":"https://api.github.com/repos//git/blobs/be9b12a99001aa66a5628fc29deace5094b30c05"},{"path":"_pages/centre-de-ressources/le-centre-de-ressources-montaigne","mode":"040000","type":"tree","sha":"b3f72050bbf67502fe95a49475df6230fd266ccb","url":"https://api.github.com/repos//git/trees/b3f72050bbf67502fe95a49475df6230fd266ccb"},{"path":"_pages/centre-de-ressources/le-centre-de-ressources-montaigne/index.html","mode":"100644","type":"blob","sha":"703a12cde6b3a4d9def353259a9f9ab102476d01","size":3269,"url":"https://api.github.com/repos//git/blobs/703a12cde6b3a4d9def353259a9f9ab102476d01"},{"path":"_pages/centre-de-ressources/le-crm-lequipe-qui-vous-accueille","mode":"040000","type":"tree","sha":"c8617628be8d488af31b0dab796f13b01bc8a6ba","url":"https://api.github.com/repos//git/trees/c8617628be8d488af31b0dab796f13b01bc8a6ba"},{"path":"_pages/centre-de-ressources/le-crm-lequipe-qui-vous-accueille/index.html","mode":"100644","type":"blob","sha":"c1dc1e5bf8cc561eb45f70370f3cb3b5ef4e5369","size":1517,"url":"https://api.github.com/repos//git/blobs/c1dc1e5bf8cc561eb45f70370f3cb3b5ef4e5369"},{"path":"_pages/centre-de-ressources/memoires","mode":"040000","type":"tree","sha":"79820eabc0d7e10a2a631e85448d8c135a649d72","url":"https://api.github.com/repos//git/trees/79820eabc0d7e10a2a631e85448d8c135a649d72"},{"path":"_pages/centre-de-ressources/memoires/index.html","mode":"100644","type":"blob","sha":"00a493e73e60da69ba578f291af3b7f0255b873a","size":2167,"url":"https://api.github.com/repos//git/blobs/00a493e73e60da69ba578f291af3b7f0255b873a"},{"path":"_pages/colloque-isiat","mode":"040000","type":"tree","sha":"b445d06929a402f81cb6cc03b96d1bbe240173d2","url":"https://api.github.com/repos//git/trees/b445d06929a402f81cb6cc03b96d1bbe240173d2"},{"path":"_pages/colloque-isiat/index.html","mode":"100644","type":"blob","sha":"196376fd39f367d8d4eac01d2805528a59558738","size":109,"url":"https://api.github.com/repos//git/blobs/196376fd39f367d8d4eac01d2805528a59558738"},{"path":"_pages/diplomes","mode":"040000","type":"tree","sha":"6c55769db290097a9945aabfddf4b7a1a790107e","url":"https://api.github.com/repos//git/trees/6c55769db290097a9945aabfddf4b7a1a790107e"},{"path":"_pages/diplomes/but","mode":"040000","type":"tree","sha":"dbfd31a7b210a5d5b51af61a5a336c0be4761e92","url":"https://api.github.com/repos//git/trees/dbfd31a7b210a5d5b51af61a5a336c0be4761e92"},{"path":"_pages/diplomes/but/animation-sociale-et-socioculturelle","mode":"040000","type":"tree","sha":"739c1c9c03a1b4a7a574635da454287a60af8707","url":"https://api.github.com/repos//git/trees/739c1c9c03a1b4a7a574635da454287a60af8707"},{"path":"_pages/diplomes/but/animation-sociale-et-socioculturelle/index.html","mode":"100644","type":"blob","sha":"01864634b5a73dfa5732e12f7d9a2c2767a445b8","size":5773,"url":"https://api.github.com/repos//git/blobs/01864634b5a73dfa5732e12f7d9a2c2767a445b8"},{"path":"_pages/diplomes/but/communication-des-organisations","mode":"040000","type":"tree","sha":"644d44ce138752d6cf86082504f79566a744d8d9","url":"https://api.github.com/repos//git/trees/644d44ce138752d6cf86082504f79566a744d8d9"},{"path":"_pages/diplomes/but/communication-des-organisations/index.html","mode":"100644","type":"blob","sha":"67fc950ca8e45345f3dd8b08547125604d8bee37","size":6015,"url":"https://api.github.com/repos//git/blobs/67fc950ca8e45345f3dd8b08547125604d8bee37"},{"path":"_pages/diplomes/but/index.html","mode":"100644","type":"blob","sha":"be9cd1e2a950896b4685643577221a549f349238","size":6001,"url":"https://api.github.com/repos//git/blobs/be9cd1e2a950896b4685643577221a549f349238"},{"path":"_pages/diplomes/but/information-numerique-dans-les-organisations","mode":"040000","type":"tree","sha":"a8f61bf02eaa04068149dce95b87e4644b9bf416","url":"https://api.github.com/repos//git/trees/a8f61bf02eaa04068149dce95b87e4644b9bf416"},{"path":"_pages/diplomes/but/information-numerique-dans-les-organisations/index.html","mode":"100644","type":"blob","sha":"4fde831480e0c8b8395fb2a34e63862f9d2c40e4","size":5932,"url":"https://api.github.com/repos//git/blobs/4fde831480e0c8b8395fb2a34e63862f9d2c40e4"},{"path":"_pages/diplomes/but/metiers-du-livre-et-du-patrimoine","mode":"040000","type":"tree","sha":"81be30d506e17da88105c19d78a91c1e16947bec","url":"https://api.github.com/repos//git/trees/81be30d506e17da88105c19d78a91c1e16947bec"},{"path":"_pages/diplomes/but/metiers-du-livre-et-du-patrimoine/index.html","mode":"100644","type":"blob","sha":"e17e9023fa8273eadfe83c165535031873731a8b","size":6494,"url":"https://api.github.com/repos//git/blobs/e17e9023fa8273eadfe83c165535031873731a8b"},{"path":"_pages/diplomes/but/metiers-du-multimedia-et-de-linternet","mode":"040000","type":"tree","sha":"9f6e8ffd2d5191b9e9baa51d576fb7900ffc2043","url":"https://api.github.com/repos//git/trees/9f6e8ffd2d5191b9e9baa51d576fb7900ffc2043"},{"path":"_pages/diplomes/but/metiers-du-multimedia-et-de-linternet/index.html","mode":"100644","type":"blob","sha":"e7b7076a07931fdf3d756518e9596a53a91291b2","size":5724,"url":"https://api.github.com/repos//git/blobs/e7b7076a07931fdf3d756518e9596a53a91291b2"},{"path":"_pages/diplomes/but/publicite","mode":"040000","type":"tree","sha":"5b615c0c9843227875a93f71c1c376a6c4084e0f","url":"https://api.github.com/repos//git/trees/5b615c0c9843227875a93f71c1c376a6c4084e0f"},{"path":"_pages/diplomes/but/publicite/index.html","mode":"100644","type":"blob","sha":"79cc52ba62b8ee181c12f21b44a6b21328c3724f","size":4526,"url":"https://api.github.com/repos//git/blobs/79cc52ba62b8ee181c12f21b44a6b21328c3724f"},{"path":"_pages/diplomes/but/villes-et-territoires-durables","mode":"040000","type":"tree","sha":"d3771bc397834578f8b40d596285892d2674c0eb","url":"https://api.github.com/repos//git/trees/d3771bc397834578f8b40d596285892d2674c0eb"},{"path":"_pages/diplomes/but/villes-et-territoires-durables/index.html","mode":"100644","type":"blob","sha":"9775471f34e5ad550c16fc9b1e33e795e0c4c7c3","size":5631,"url":"https://api.github.com/repos//git/blobs/9775471f34e5ad550c16fc9b1e33e795e0c4c7c3"},{"path":"_pages/diplomes/diplomes-universitaires","mode":"040000","type":"tree","sha":"0d610f667c7f13bc3c2946039b9eeadb92faeed4","url":"https://api.github.com/repos//git/trees/0d610f667c7f13bc3c2946039b9eeadb92faeed4"},{"path":"_pages/diplomes/diplomes-universitaires/etudes-technologiques-internationales-dueti","mode":"040000","type":"tree","sha":"d2422fd931abca86872a6e0e8459803163e988ec","url":"https://api.github.com/repos//git/trees/d2422fd931abca86872a6e0e8459803163e988ec"},{"path":"_pages/diplomes/diplomes-universitaires/etudes-technologiques-internationales-dueti/index.html","mode":"100644","type":"blob","sha":"32685575c7d4655bdbdd3146be2de3d8672f0877","size":6552,"url":"https://api.github.com/repos//git/blobs/32685575c7d4655bdbdd3146be2de3d8672f0877"},{"path":"_pages/diplomes/diplomes-universitaires/index.html","mode":"100644","type":"blob","sha":"a934ac19211fe5660c52a7a0b2e8ac37f3e95462","size":868,"url":"https://api.github.com/repos//git/blobs/a934ac19211fe5660c52a7a0b2e8ac37f3e95462"},{"path":"_pages/diplomes/dut","mode":"040000","type":"tree","sha":"dee1cb9bb84eb5aa339741ec566b2e67008ce3f4","url":"https://api.github.com/repos//git/trees/dee1cb9bb84eb5aa339741ec566b2e67008ce3f4"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc","mode":"040000","type":"tree","sha":"0c884eeb77a5ca33303bfc61f151e1babcf14102","url":"https://api.github.com/repos//git/trees/0c884eeb77a5ca33303bfc61f151e1babcf14102"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/contenus-de-formation","mode":"040000","type":"tree","sha":"bab13da88a519c35e855e8ff54204922c89fe89e","url":"https://api.github.com/repos//git/trees/bab13da88a519c35e855e8ff54204922c89fe89e"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/contenus-de-formation/index.html","mode":"100644","type":"blob","sha":"f71b8d929a001ea90a205a0aefcae2e44860743d","size":15929,"url":"https://api.github.com/repos//git/blobs/f71b8d929a001ea90a205a0aefcae2e44860743d"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/equipe-pedagogique","mode":"040000","type":"tree","sha":"6e2637e822912b373a7958fe63dbbd27a76bdd51","url":"https://api.github.com/repos//git/trees/6e2637e822912b373a7958fe63dbbd27a76bdd51"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/equipe-pedagogique/index.html","mode":"100644","type":"blob","sha":"0b0abb732ecc63c55ff738d4c3155b0b5171bd38","size":2832,"url":"https://api.github.com/repos//git/blobs/0b0abb732ecc63c55ff738d4c3155b0b5171bd38"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/index.html","mode":"100644","type":"blob","sha":"451f0d4208e681f5623dd1a90957107890ce446a","size":3962,"url":"https://api.github.com/repos//git/blobs/451f0d4208e681f5623dd1a90957107890ce446a"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/metiers-et-debouches","mode":"040000","type":"tree","sha":"08ccc4684a4ce77fe33d15a125b324c58f8402f6","url":"https://api.github.com/repos//git/trees/08ccc4684a4ce77fe33d15a125b324c58f8402f6"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/metiers-et-debouches/index.html","mode":"100644","type":"blob","sha":"8d54d9bb0e73e2391fbfe3e65966887907b0587b","size":6091,"url":"https://api.github.com/repos//git/blobs/8d54d9bb0e73e2391fbfe3e65966887907b0587b"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/modalites-de-recrutement","mode":"040000","type":"tree","sha":"cc6d955c5cf3a6558c680f554884be8f2790b85f","url":"https://api.github.com/repos//git/trees/cc6d955c5cf3a6558c680f554884be8f2790b85f"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/modalites-de-recrutement/index.html","mode":"100644","type":"blob","sha":"3126dc6fdae7bbbdcc42627d54d847d58c82271d","size":3763,"url":"https://api.github.com/repos//git/blobs/3126dc6fdae7bbbdcc42627d54d847d58c82271d"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/stages-et-memoires","mode":"040000","type":"tree","sha":"e14ee5fa6f0d250115b6001851cb91566331bfb2","url":"https://api.github.com/repos//git/trees/e14ee5fa6f0d250115b6001851cb91566331bfb2"},{"path":"_pages/diplomes/dut/animation-sociale-et-socioculturelle-assc/stages-et-memoires/index.html","mode":"100644","type":"blob","sha":"5ce10e33618327d07db7aad03dad0e7596f85952","size":4983,"url":"https://api.github.com/repos//git/blobs/5ce10e33618327d07db7aad03dad0e7596f85952"},{"path":"_pages/diplomes/dut/communication-des-organisations-co","mode":"040000","type":"tree","sha":"67557b8e35bc39cea086a289208213434b08c3ff","url":"https://api.github.com/repos//git/trees/67557b8e35bc39cea086a289208213434b08c3ff"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an","mode":"040000","type":"tree","sha":"f4e38915f21d1f85afa07161ab11a1a553f7f62d","url":"https://api.github.com/repos//git/trees/f4e38915f21d1f85afa07161ab11a1a553f7f62d"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an/candidatures","mode":"040000","type":"tree","sha":"72a6931d89b339c2f02c70dc21fc1bb156e7aba4","url":"https://api.github.com/repos//git/trees/72a6931d89b339c2f02c70dc21fc1bb156e7aba4"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an/candidatures/index.html","mode":"100644","type":"blob","sha":"f3a89c874a58f22e395ee644d501c565400365ad","size":3156,"url":"https://api.github.com/repos//git/blobs/f3a89c874a58f22e395ee644d501c565400365ad"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an/cout-de-la-formation","mode":"040000","type":"tree","sha":"f5b4b91c9bf818940a3f16b57e69d8a5ada07e9c","url":"https://api.github.com/repos//git/trees/f5b4b91c9bf818940a3f16b57e69d8a5ada07e9c"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an/cout-de-la-formation/index.html","mode":"100644","type":"blob","sha":"a4643202a2e470ad210b098f0c1ab391a36769f6","size":1025,"url":"https://api.github.com/repos//git/blobs/a4643202a2e470ad210b098f0c1ab391a36769f6"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an/index.html","mode":"100644","type":"blob","sha":"274396052ab4645b41104dd5d4063151a0bde5d3","size":2794,"url":"https://api.github.com/repos//git/blobs/274396052ab4645b41104dd5d4063151a0bde5d3"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an/organisation-pedagogique","mode":"040000","type":"tree","sha":"f28558fba7344f7763d5a04bbeb278905f8bef77","url":"https://api.github.com/repos//git/trees/f28558fba7344f7763d5a04bbeb278905f8bef77"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-1-an/organisation-pedagogique/index.html","mode":"100644","type":"blob","sha":"dd03c75cd29ddca9a225cc10e25b174f8fdb460c","size":2768,"url":"https://api.github.com/repos//git/blobs/dd03c75cd29ddca9a225cc10e25b174f8fdb460c"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans","mode":"040000","type":"tree","sha":"4cc6b359cc8bfa1b49a33d8be4e4e7d17344dee8","url":"https://api.github.com/repos//git/trees/4cc6b359cc8bfa1b49a33d8be4e4e7d17344dee8"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans/candidatures","mode":"040000","type":"tree","sha":"9ebaa2607d637453e6b8431558d62f3f2ba2af6b","url":"https://api.github.com/repos//git/trees/9ebaa2607d637453e6b8431558d62f3f2ba2af6b"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans/candidatures/index.html","mode":"100644","type":"blob","sha":"ceb7e7b3eb9f6ff41703a95d8a93910c51bf8c28","size":1928,"url":"https://api.github.com/repos//git/blobs/ceb7e7b3eb9f6ff41703a95d8a93910c51bf8c28"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans/cout-de-la-formation","mode":"040000","type":"tree","sha":"9d221a96e49dfb148b926b7751a8100476fed85f","url":"https://api.github.com/repos//git/trees/9d221a96e49dfb148b926b7751a8100476fed85f"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans/cout-de-la-formation/index.html","mode":"100644","type":"blob","sha":"9971e9f5cb1c5b57bfc8b47ddb4af8f24e7b5cc6","size":736,"url":"https://api.github.com/repos//git/blobs/9971e9f5cb1c5b57bfc8b47ddb4af8f24e7b5cc6"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans/index.html","mode":"100644","type":"blob","sha":"a0fd5131a9b20573c7e03c58c1af2e1378ec2583","size":2856,"url":"https://api.github.com/repos//git/blobs/a0fd5131a9b20573c7e03c58c1af2e1378ec2583"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans/organisation-pedagogique","mode":"040000","type":"tree","sha":"3d541b529af709f9d6fead7bd178195edafa4c32","url":"https://api.github.com/repos//git/trees/3d541b529af709f9d6fead7bd178195edafa4c32"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/en-2-ans/organisation-pedagogique/index.html","mode":"100644","type":"blob","sha":"7865aa7e17a1ad2f22ebd9f7ffa0277ca873edb6","size":4207,"url":"https://api.github.com/repos//git/blobs/7865aa7e17a1ad2f22ebd9f7ffa0277ca873edb6"},{"path":"_pages/diplomes/dut/communication-des-organisations-co/index.html","mode":"100644","type":"blob","sha":"6a51a5d50b8a2407b985f573baf93c5136da7324","size":2210,"url":"https://api.github.com/repos//git/blobs/6a51a5d50b8a2407b985f573baf93c5136da7324"},{"path":"_pages/diplomes/dut/gestion-urbaine","mode":"040000","type":"tree","sha":"bcf0cdb7daef24fdcf8f34c4bf58b6ef71c59ef9","url":"https://api.github.com/repos//git/trees/bcf0cdb7daef24fdcf8f34c4bf58b6ef71c59ef9"},{"path":"_pages/diplomes/dut/gestion-urbaine/index.html","mode":"100644","type":"blob","sha":"515a424d87f45bbe30ccddabaef62e5af6368b04","size":5243,"url":"https://api.github.com/repos//git/blobs/515a424d87f45bbe30ccddabaef62e5af6368b04"},{"path":"_pages/diplomes/dut/gestion-urbaine/metiers-et-debouches","mode":"040000","type":"tree","sha":"e004127c49fa013938160f996602056c7d7430f9","url":"https://api.github.com/repos//git/trees/e004127c49fa013938160f996602056c7d7430f9"},{"path":"_pages/diplomes/dut/gestion-urbaine/metiers-et-debouches/index.html","mode":"100644","type":"blob","sha":"0b7dedcf722802931c319dd9a826128ed1cbf296","size":1471,"url":"https://api.github.com/repos//git/blobs/0b7dedcf722802931c319dd9a826128ed1cbf296"},{"path":"_pages/diplomes/dut/gestion-urbaine/modalites-de-recrutement","mode":"040000","type":"tree","sha":"88ee1c2cc8957f535000a428870f06c4f86f2045","url":"https://api.github.com/repos//git/trees/88ee1c2cc8957f535000a428870f06c4f86f2045"},{"path":"_pages/diplomes/dut/gestion-urbaine/modalites-de-recrutement/index.html","mode":"100644","type":"blob","sha":"fdfe6597be1ead9f93b5dba650377e6991909513","size":3589,"url":"https://api.github.com/repos//git/blobs/fdfe6597be1ead9f93b5dba650377e6991909513"},{"path":"_pages/diplomes/dut/gestion-urbaine/stages-et-memoire","mode":"040000","type":"tree","sha":"7cefe2818fec7ddf04922a6e7fa9caf41505e654","url":"https://api.github.com/repos//git/trees/7cefe2818fec7ddf04922a6e7fa9caf41505e654"},{"path":"_pages/diplomes/dut/gestion-urbaine/stages-et-memoire/index.html","mode":"100644","type":"blob","sha":"bdfa3f0376e6a1b43bd7acdac29086fc97d5a09a","size":2636,"url":"https://api.github.com/repos//git/blobs/bdfa3f0376e6a1b43bd7acdac29086fc97d5a09a"},{"path":"_pages/diplomes/dut/index.html","mode":"100644","type":"blob","sha":"db984c48f2c22be2dc57f9b18d5b1d9cbb0ec89e","size":3426,"url":"https://api.github.com/repos//git/blobs/db984c48f2c22be2dc57f9b18d5b1d9cbb0ec89e"},{"path":"_pages/diplomes/dut/information-numerique-dans-les-organisations-infonum","mode":"040000","type":"tree","sha":"78a7bdc030cfc6361a6c47dafb97f327169d01ea","url":"https://api.github.com/repos//git/trees/78a7bdc030cfc6361a6c47dafb97f327169d01ea"},{"path":"_pages/diplomes/dut/information-numerique-dans-les-organisations-infonum/index.html","mode":"100644","type":"blob","sha":"e14c314f040b03e33ab08b77d17534d8e2bb2952","size":3766,"url":"https://api.github.com/repos//git/blobs/e14c314f040b03e33ab08b77d17534d8e2bb2952"},{"path":"_pages/diplomes/dut/metiers-du-livre-option-bibliothequesmediatheques","mode":"040000","type":"tree","sha":"3d03f5952403a0da36842212b760ffebe8c91c09","url":"https://api.github.com/repos//git/trees/3d03f5952403a0da36842212b760ffebe8c91c09"},{"path":"_pages/diplomes/dut/metiers-du-livre-option-bibliothequesmediatheques/index.html","mode":"100644","type":"blob","sha":"b174fa5949919b08274a77ecf8cd9d8c3a2eeab7","size":1113,"url":"https://api.github.com/repos//git/blobs/b174fa5949919b08274a77ecf8cd9d8c3a2eeab7"},{"path":"_pages/diplomes/dut/metiers-du-livre-option-editionlibrairie","mode":"040000","type":"tree","sha":"16c7425fcbbe65d45ce4a0fe3f60820ad5a0f6e6","url":"https://api.github.com/repos//git/trees/16c7425fcbbe65d45ce4a0fe3f60820ad5a0f6e6"},{"path":"_pages/diplomes/dut/metiers-du-livre-option-editionlibrairie/index.html","mode":"100644","type":"blob","sha":"26dcc850ef8227f9a29aad24e90f47cd2962fb7c","size":1065,"url":"https://api.github.com/repos//git/blobs/26dcc850ef8227f9a29aad24e90f47cd2962fb7c"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi","mode":"040000","type":"tree","sha":"9d2ca4fc91bb9b451cd89d5ee78418d95c8af8e6","url":"https://api.github.com/repos//git/trees/9d2ca4fc91bb9b451cd89d5ee78418d95c8af8e6"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/apres-mmi","mode":"040000","type":"tree","sha":"55f73fc9c94741ff84ac8bfa39e4d82e670d0da6","url":"https://api.github.com/repos//git/trees/55f73fc9c94741ff84ac8bfa39e4d82e670d0da6"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/apres-mmi/index.html","mode":"100644","type":"blob","sha":"69a4ebac1ad62a04aed00b03434ae83066ef5b08","size":1104,"url":"https://api.github.com/repos//git/blobs/69a4ebac1ad62a04aed00b03434ae83066ef5b08"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/candidature-et-recrutement","mode":"040000","type":"tree","sha":"2dd73f68f5c429fc733d24cf5c9128299ac5694f","url":"https://api.github.com/repos//git/trees/2dd73f68f5c429fc733d24cf5c9128299ac5694f"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/candidature-et-recrutement/index.html","mode":"100644","type":"blob","sha":"9c9a1afff140ef6b33ef8b5b44d929d157c9642d","size":1547,"url":"https://api.github.com/repos//git/blobs/9c9a1afff140ef6b33ef8b5b44d929d157c9642d"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/contenu-de-la-formation","mode":"040000","type":"tree","sha":"fd42b42041e6410efff9c596ac1c22e7debe2631","url":"https://api.github.com/repos//git/trees/fd42b42041e6410efff9c596ac1c22e7debe2631"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/contenu-de-la-formation/index.html","mode":"100644","type":"blob","sha":"02783d9f1e4f4c3d484348839cf92536fcdc989b","size":1371,"url":"https://api.github.com/repos//git/blobs/02783d9f1e4f4c3d484348839cf92536fcdc989b"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/index.html","mode":"100644","type":"blob","sha":"10ec0923216b331feda8dfd8da49e59cf48b35e8","size":2520,"url":"https://api.github.com/repos//git/blobs/10ec0923216b331feda8dfd8da49e59cf48b35e8"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/projets-et-stages","mode":"040000","type":"tree","sha":"6a1bfdaa85d5fd08c9dcfaec9a16d24194daf21c","url":"https://api.github.com/repos//git/trees/6a1bfdaa85d5fd08c9dcfaec9a16d24194daf21c"},{"path":"_pages/diplomes/dut/metiers-du-multimedia-et-de-linternet-mmi/projets-et-stages/index.html","mode":"100644","type":"blob","sha":"e4341700e73567c7f11c75d41ba2067ed9bb2927","size":2581,"url":"https://api.github.com/repos//git/blobs/e4341700e73567c7f11c75d41ba2067ed9bb2927"},{"path":"_pages/diplomes/dut/publicite","mode":"040000","type":"tree","sha":"1eb074e57e4724bebfccc1ec0b571549e4584d36","url":"https://api.github.com/repos//git/trees/1eb074e57e4724bebfccc1ec0b571549e4584d36"},{"path":"_pages/diplomes/dut/publicite/atouts-pub-bordeaux","mode":"040000","type":"tree","sha":"e00b23e0cbbbc518e4cbb8cb4e32001c9dada68a","url":"https://api.github.com/repos//git/trees/e00b23e0cbbbc518e4cbb8cb4e32001c9dada68a"},{"path":"_pages/diplomes/dut/publicite/atouts-pub-bordeaux/index.html","mode":"100644","type":"blob","sha":"a9e93b4dce909680ccbf0c0bb0a8cb62d30fff10","size":1708,"url":"https://api.github.com/repos//git/blobs/a9e93b4dce909680ccbf0c0bb0a8cb62d30fff10"},{"path":"_pages/diplomes/dut/publicite/candidature-et-recrutement","mode":"040000","type":"tree","sha":"3562d6bc602b7a25a3228477343b99477574829d","url":"https://api.github.com/repos//git/trees/3562d6bc602b7a25a3228477343b99477574829d"},{"path":"_pages/diplomes/dut/publicite/candidature-et-recrutement/index.html","mode":"100644","type":"blob","sha":"a46ad710ac8628d97c858f9880941ce4d4f7d3da","size":2656,"url":"https://api.github.com/repos//git/blobs/a46ad710ac8628d97c858f9880941ce4d4f7d3da"},{"path":"_pages/diplomes/dut/publicite/index.html","mode":"100644","type":"blob","sha":"d4009a7c06991c8a11343e38a9d9e48ddd8a8490","size":1438,"url":"https://api.github.com/repos//git/blobs/d4009a7c06991c8a11343e38a9d9e48ddd8a8490"},{"path":"_pages/diplomes/dut/publicite/programme-de-formation","mode":"040000","type":"tree","sha":"1060c0eab32c831c76209832cffc1bfc10893b55","url":"https://api.github.com/repos//git/trees/1060c0eab32c831c76209832cffc1bfc10893b55"},{"path":"_pages/diplomes/dut/publicite/programme-de-formation/index.html","mode":"100644","type":"blob","sha":"e83bcfb2b2f171ebe9f3a2f0ab9d257daa0a1018","size":2759,"url":"https://api.github.com/repos//git/blobs/e83bcfb2b2f171ebe9f3a2f0ab9d257daa0a1018"},{"path":"_pages/diplomes/index.html","mode":"100644","type":"blob","sha":"9f19b234320473cb862a119f8fd478ee6963ed58","size":5640,"url":"https://api.github.com/repos//git/blobs/9f19b234320473cb862a119f8fd478ee6963ed58"},{"path":"_pages/diplomes/licences-pro","mode":"040000","type":"tree","sha":"7202f973ce8bb66c319c028a840ba68f8e81e70c","url":"https://api.github.com/repos//git/trees/7202f973ce8bb66c319c028a840ba68f8e81e70c"},{"path":"_pages/diplomes/licences-pro/coordination-de-projets-de-developpement-social-et-culturel","mode":"040000","type":"tree","sha":"5df2dfafa0d95f03535000a1a336cb5d20a1cfd5","url":"https://api.github.com/repos//git/trees/5df2dfafa0d95f03535000a1a336cb5d20a1cfd5"},{"path":"_pages/diplomes/licences-pro/coordination-de-projets-de-developpement-social-et-culturel/formation-continue-une-organisation-specifique","mode":"040000","type":"tree","sha":"deb12311193912ffb2decdda64c8e2bfb0e534a0","url":"https://api.github.com/repos//git/trees/deb12311193912ffb2decdda64c8e2bfb0e534a0"},{"path":"_pages/diplomes/licences-pro/coordination-de-projets-de-developpement-social-et-culturel/formation-continue-une-organisation-specifique/index.html","mode":"100644","type":"blob","sha":"759c33a8a40e37ba81df597db297c823bce32959","size":1820,"url":"https://api.github.com/repos//git/blobs/759c33a8a40e37ba81df597db297c823bce32959"},{"path":"_pages/diplomes/licences-pro/coordination-de-projets-de-developpement-social-et-culturel/index.html","mode":"100644","type":"blob","sha":"3ac15028563862f0b052dae5cffcfd13792fc843","size":2638,"url":"https://api.github.com/repos//git/blobs/3ac15028563862f0b052dae5cffcfd13792fc843"},{"path":"_pages/diplomes/licences-pro/index.html","mode":"100644","type":"blob","sha":"6aa4a6a0950b294f06ba01e464108a254e2d0437","size":3810,"url":"https://api.github.com/repos//git/blobs/6aa4a6a0950b294f06ba01e464108a254e2d0437"},{"path":"_pages/diplomes/licences-pro/libraire","mode":"040000","type":"tree","sha":"772fe7d07f3c1b75398c82378e15fadc475fad5c","url":"https://api.github.com/repos//git/trees/772fe7d07f3c1b75398c82378e15fadc475fad5c"},{"path":"_pages/diplomes/licences-pro/libraire/index.html","mode":"100644","type":"blob","sha":"a7c9c7af3cecee2bcc621ae549c273853dd08e52","size":2457,"url":"https://api.github.com/repos//git/blobs/a7c9c7af3cecee2bcc621ae549c273853dd08e52"},{"path":"_pages/diplomes/licences-pro/mediation-par-le-jeu-et-gestion-de-ludotheque","mode":"040000","type":"tree","sha":"e94e8c28315420a63a9edf6d7bf87bcb8e1d9958","url":"https://api.github.com/repos//git/trees/e94e8c28315420a63a9edf6d7bf87bcb8e1d9958"},{"path":"_pages/diplomes/licences-pro/mediation-par-le-jeu-et-gestion-de-ludotheque/contenus-de-formation","mode":"040000","type":"tree","sha":"59e611bf61a9d159fc27eec8504adebf3025217b","url":"https://api.github.com/repos//git/trees/59e611bf61a9d159fc27eec8504adebf3025217b"},{"path":"_pages/diplomes/licences-pro/mediation-par-le-jeu-et-gestion-de-ludotheque/contenus-de-formation/index.html","mode":"100644","type":"blob","sha":"2a626e98bb8c4a950df699cd6df3681d1bfd1b87","size":2491,"url":"https://api.github.com/repos//git/blobs/2a626e98bb8c4a950df699cd6df3681d1bfd1b87"},{"path":"_pages/diplomes/licences-pro/mediation-par-le-jeu-et-gestion-de-ludotheque/index.html","mode":"100644","type":"blob","sha":"1220031b7aeac26b0547df1ee5cef30d9992c61b","size":4821,"url":"https://api.github.com/repos//git/blobs/1220031b7aeac26b0547df1ee5cef30d9992c61b"},{"path":"_pages/diplomes/licences-pro/mediations-de-linformation-numerique-et-des-donnees-mind","mode":"040000","type":"tree","sha":"8a5917a6ef3279c135754c95faa6b29a4a74f8eb","url":"https://api.github.com/repos//git/trees/8a5917a6ef3279c135754c95faa6b29a4a74f8eb"},{"path":"_pages/diplomes/licences-pro/mediations-de-linformation-numerique-et-des-donnees-mind/index.html","mode":"100644","type":"blob","sha":"3da0b37b3013cc6d8ffd46e921043f59e9d1377e","size":4573,"url":"https://api.github.com/repos/