需要帮助创建一个Git客户端'commit-msg'钩子

时间:2015-05-20 13:41:25

标签: ruby git github hook assembla

我在Assembla上安装了'ticket_status.rb'服务器端钩子。虽然这正是我正在寻找的(理论上),但在开发人员尝试推送到服务器之前,它不会标记。如果他们在推送之前已经进行了多次提交,那么返回历史记录并编辑任何无效的提交消息会变得异常令人沮丧。

我希望创建一个客户端钩子,如果在提交消息中没有引用Assembla中的打开票证,它将拒绝开发人员的提交。我假设因为它是客户端,它将无法检查票证是否在Assembla项目空间中打开。但是,如果钩子至少可以检查提交消息中是否包含'#n'(其中0< n< 10,000),则它应该捕获大多数无效的提交消息。

GitHub为客户端'commit-msg'钩子提供了示例代码。我想帮助修改下面的代码,而不是在提交消息中搜索票号(#n)(如果可能的话,在Assembla项目空间中打开票证):

#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message.  The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit.  The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".

# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"

# This example catches duplicate Signed-off-by lines.

test "" = "$(grep '^Signed-off-by: ' "$1" |
     sort | uniq -c | sed -e '/^[   ]*1[    ]/d')" || {
    echo >&2 Duplicate Signed-off-by lines.
    exit 1
} 

我还提供了服务器端钩子的源代码,如果它在提交消息中没有包含有效的打开票证号码(ticket_status.rb),则拒绝提交:

#!/usr/bin/env ruby
# -*- encoding : utf-8 -*-

#
# Reject a push to a branch if it has commits that do refer a ticket in open state
#

# ref = ARGV[0]
sha_start = ARGV[1]
sha_end = ARGV[2]

# HOOK PARAMS
space = 'space-wiki-name'
api_key = 'user-api-key'
api_secret = 'user-api-secret'
# HOOK START, end of params block

require "net/https"
require "uri"
begin
  require "json"
rescue LoadError
  require 'rubygems'
  require 'json'
end

# Check referred tickets that are in open stage
class TicketValidator
  API_URL = "https://api.assembla.com"

  attr_accessor :space, :api_key, :api_secret

  def initialize()
    @ticket_statuses = []
    @tickets = {}
  end

  def init
    init_http
    load_statuses
  end

  def check(sha, comment)
    comment.to_s.scan(/#\d+/).each do |t|
      ticket = t.tr('#', '')
      # Do not check it twice
      next if @tickets[ticket]
      ticket_js = api_call "/v1/spaces/#{space}/tickets/#{ticket}.json"

      error = nil

      if ticket_js['error'].nil?
        unless @ticket_statuses.include? ticket_js['status'].downcase
          error = "Ticket #{t} is not open!"
        end
      else
        error = ticket_js['error']
      end

      if error
        @tickets[ticket] = {:error => error, :sha => sha}
      else
        @tickets[ticket] = :ok
      end
    end
  end

  def load_statuses
    statuses = api_call "/v1/spaces/#{space}/tickets/statuses.json"
    statuses.each do |status|
      if status["state"] == 1 # open
        @ticket_statuses << status["name"].downcase
      end
    end
  end

  def api_call(uri)
    request = Net::HTTP::Get.new(uri,
                                 {'Content-Type' => 'application/json',
                                  'X-Api-Key' => api_key,
                                  'X-Api-Secret' => api_secret})
    result = @http.request(request)
    JSON.parse(result.body)
  end

  def init_http
    uri = URI.parse(API_URL)
    @http = Net::HTTP.new(uri.host, uri.port)
    @http.use_ssl = true
    @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  end

  def show_decision!
    @tickets.reject! {|_, value| value == :ok }

    unless @tickets.empty?
      puts "You have references to tickets in closed state"

      @tickets.each do |ticket, details|
        puts "\t#{details[:sha]} - ##{ticket} #{details[:error]}"
      end

      puts "Valid statuses: #{@ticket_statuses.join(', ')}"
      exit 1
    end
  end
end

class Parser
  def initialize(text, validator)
    @text = text
    @validator = validator
  end

  def parse
    commit = nil
    comment = nil

    @validator.init

    @text.to_s.split("\n").each do |line|
      if line =~ /^commit: ([a-z0-9]+)$/i
        new_commit = $1

        if comment
          @validator.check(commit, comment)
          comment = nil
        end

        commit = new_commit
      else
        comment = comment.to_s + line + "\n"
      end
    end

    # Check last commit
    @validator.check(commit, comment) if comment
  end
end

text = `git log --pretty='format:commit: %h%n%B' #{sha_start}..#{sha_end}`

@validator = TicketValidator.new
@validator.space = space
@validator.api_key = api_key
@validator.api_secret = api_secret

Parser.new(text, @validator).parse
@validator.show_decision!

非常感谢任何帮助。感谢

1 个答案:

答案 0 :(得分:0)

您可以尝试使用此commit-msg验证程序。它不是红宝石,但您可以根据需要轻松配置它,您甚至可以编写your own Assembla reference来验证其API的票号。有关详细信息,请参阅repo README。

以下是自定义引用及其关联测试文件的起点。我还没有对它进行彻底的测试,但是根据你的意愿改变它应该很容易,因为它基本上都是JavaScript。

<强> LIB /参考/ assembla.js

'use strict';

var exec = require('child_process').exec;
var https = require('https');
var util = require('util');

// HOOK PARAMS
var space = 'space-wiki-name';
var apiKey = 'user-api-key';
var apiSecret = 'user-api-secret';

function Ticket(ticket, match) {
    this.allowInSubject = true;
    this.match = match;

    this._ticket = ticket;
}

Ticket.prototype.toString = function() {
    return '#' + this._ticket;
}

Ticket.prototype.isValid = function(cb) {

    var options = {
        hostname: 'api.assembla.com',
        path: util.format('/v1/spaces/%s/tickets/%s.json', space, this._ticket),
        headers: {
            'Content-Type'  : 'application/json',
            'X-Api-Key'     : apiKey,
            'X-Api-Secret'  : apiSecret
        }
    };
    https.get(options, function(res) {
        if (res.statusCode === 404) {
            return cb(null, false); // invalid
        }

        var body = '';
        res.on('data', function(chunk) {
            body += chunk.toString();
        });

        res.on('end', function () {
            var response = body ? JSON.parse(body) : false;

            if (res.statusCode < 300 && response) {
                return cb(null, true); // valid?
            }

            console.error('warning: Reference check failed with status code %d',
                res.statusCode,
                response && response.message ? ('; reason: ' + response.message) : '');

            cb(null, false); // request errored out?
        });
    });
}

// Fake class that requires the existence of a ticket # in every commit
function TicketRequired() {
    Ticket.call(this);
    this.error = new Error('Commit should include an Assembla ticket #');
}

util.inherits(TicketRequired, Ticket);

TicketRequired.prototype.isValid = function(cb) {
    cb(null, false);
}

Ticket.parse = function(text) {
    var instances = [];
    var cb = function(match, ticket) {
        instances.push( new Ticket(ticket, match) );
    };
    text.replace(/#(-?\d+)\b/gi, cb);
    if (!instances.length) {
        // maybe should skip merge commits here
        instances.push(new TicketRequired());
    }
    return instances;
}

module.exports = Ticket;

<强>测试/参考/ assembla.js

'use strict';

var assert = require('assert');
var Ticket = require('../../lib/references/assembla');

describe('references/assembla', function() {

    it('should validate correctly using the API', function(done) {
        this.timeout(5000); // allow enough time

        var tickets = Ticket.parse('Change functionality\n\nFixes #13 and #9999 (invalid)');

        var ct = 0;
        var checkDone = function() {
            if (++ct == tickets.length) done();
        };
        var valid = [true, false];

        valid.forEach(function(val, idx) {
            tickets[idx].isValid(function(err, valid) {
                assert.equal(valid, val, tickets[idx].toString());
                checkDone();
            });
        });
    });

    it('should require a ticket #', function() {
        var tickets = Ticket.parse('Commit message without any ticket ref #');

        assert.equal(tickets.length, 1);
        assert.equal(tickets[0].error.message, 'Commit should include an Assembla ticket #');
    });
});